Skip to content
djust/docs
Appearance
Mode
djust.org →
Browse documentation

Changelog

All notable changes to djust will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

[1.1.0rc7] - 2026-07-10

Added

  • Django {% regroup %} support in the Rust template engine (#2023). {% regroup <expr> by <attr> as <var> %} now regroups a flat sequence into [{"grouper": key, "list": [...]}, ...], matching Django's RegroupNode consecutive-grouping semantics (input order preserved, never pre-sorted). Implemented as a built-in assign tag handler (RegroupTagHandler) plus a JSON-aware resolve_tag_arg in renderer.rs that brings the assign-tag arg path to parity with CustomTag (structured list/object args are JSON-encoded instead of collapsing to the opaque [List]/[Object] placeholder). <attr> supports dotted paths (author.team). Known limitations vs. Django (documented on RegroupTagHandler): filter expressions on the source (cities|dictsort:"country") are unsupported. (A context key whose name matched the <attr> token could originally shadow the per-item lookup; that footgun's durable fix — passing the keyword/name operands unresolved — landed in #2041, see below.) Regression coverage in test_regroup_tag.py drives the real Rust engine via both render_template and RustLiveView.render_with_diff with a Django-parity anchor.
  • Debug panel is now dockable (bottom/left/right) and resizable. The dev toolbar was a hardcoded full-width 400px bottom dock, which permanently covered bottom-anchored app UI — a chat input, sticky footer, or bottom nav — while open. New dock buttons in the panel header switch between bottom (default), left, and right edge docking (side docks are full-height panels that leave the bottom of the page visible); a drag handle on the panel's inner edge resizes it (clamped to 160px–90vh height / 320px–90vw width); dock position and size persist per view via the existing localStorage UI-state path, with validation on load. The floating toggle button moves out from under the open panel and returns to its corner on close. DjustDebugPanel's previously-dead config.position now seeds the initial dock, and setDock('bottom'|'left'|'right') is available at runtime. New cases in tests/js/debug_panel_dock.test.js.

Fixed

  • {% regroup <src> by <attr> as <var> %} no longer groups by the wrong attribute when a context key is named after the <attr> token (#2041). The Rust engine resolved every assign-tag arg against the render context before calling the handler, so the <attr> operand was resolved too: a top-level context variable named like the attribute (country, type, category, … — djust auto-exposes public view attrs to the template context) shadowed the per-item lookup. <attr> arrived as that key's value instead of the literal attribute name, and the grouping was silently wrong (every row collapsing into one bogus group). Django never resolves <attr> against the outer context. The #2023 mitigation only logger.warning-ed when the resolved attr wasn't a bare identifier — it missed the shadow-to-an-identifier-value case (country → "usa"). Durable fix: AssignTagHandler now carries a RESOLVE_ARG_POSITIONS class attribute (set[int] | None; None = resolve all args, the unchanged default for any assign tag that doesn't opt in). register_assign_tag_handler reads it once at registration, and one shared resolve_assign_tag_args helper — routed through all four AssignTag dispatch sites in renderer.rs (the #1646 parallel-path cure) — resolves ONLY the declared positions and passes the rest as literal tokens. RegroupTagHandler declares {0}, so only the <expr> source is resolved (still JSON-encoded as before); by / <attr> / as / <var> stay literal, making the shadow impossible while dotted by author.team continues to resolve as a per-item path. Removes the now-obsolete logger.warning + _IDENTIFIER_RE shadow heuristic from regroup.py. Regression coverage in test_regroup_tag.py: two end-to-end shadow reproducers (a plain country key and a dotted author.team, both via render_template) plus a pin on RegroupTagHandler.RESOLVE_ARG_POSITIONS == {0}; gate-off verified (forcing the mask off turns both reproducers red).

  • {% ... %} block custom tags no longer collapse a list/object argument to the opaque [List]/[Object] placeholder (#2042). The Rust template engine had THREE tag-dispatch arg-resolution branches, but only CustomTag and AssignTag (post-#2023) JSON-encoded structured (list/object) args; BlockCustomTag used a hand-copied inline resolver that skipped the JSON encoding, so a block handler received "[List]"/"[Object]" and lost the payload. Extracted ONE shared module-level value_to_arg_string (previously duplicated inline in resolve_tag_arg and the CustomTag arm) and routed all three branches through it, folding BlockCustomTag onto the same resolve_tag_arg helper AssignTag already uses — the #1646 parallel-path cure (retire the collapse class, not fix 2-of-3). CustomTag/AssignTag behavior is byte-for-byte preserved (filter-aware get_value for CustomTag, scalars unchanged everywhere); the only behavior change is BlockCustomTag now JSON-encodes list/object args. New end-to-end Rust test in crates/djust_templates/tests/test_block_custom_tag_arg_json_2042.rs (real BlockCustomTag dispatch through a registered Python block handler; single #[test] because parallel Python::attach across cargo's default harness deadlocks) plus 5 Python-free unit tests in renderer.rs pinning the shared value_to_arg_string/resolve_tag_arg contract; gate-off verified (reverting only the BlockCustomTag routing turns the integration test red with got "[List]").

  • A Django Model/QuerySet stored on PUBLIC LiveView state (enable_state_snapshot) no longer silently comes back as a plain dict after a back-navigation restore — it now fails loud and early instead. _capture_snapshot_state (the client-signed state_snapshot_signed mount-emission path in runtime.py) reuses djust.serialization.DjangoJSONEncoder, which — unlike the plain encoder — does know how to serialize a Model (_serialize_model_safely). So self.user = request.user in mount() silently succeeded the JSON round-trip and shipped as a lossy, disconnected field-value dict; on the next back-navigation restore, self.user came back that dict, not a User, and a handler calling a model method on it (self.user.get_full_name()) broke with a confusing, origin-unclear AttributeError far from the actual mistake. Sibling of #1994 (the same shape for PRIVATE state, fixed by re-hydrating a DB ref) — but public, client-signed state must not attempt automatic re-hydration by pk (that's exactly the mass-assignment shape state_snapshot_signed's HMAC signing exists to prevent), so the fix instead rejects early: a new LiveView._reject_orm_value_in_state_persistence guard, applied only when _capture_snapshot_state(strict=True) (the real runtime.py persistence caller), raises NonPersistableStateError (a TypeError subclass) in DEBUG with actionable guidance (store the pk, refetch in the handler — e.g. self.user_id = user.pk) or logs a warning and skips the attribute in production. The dedicated exception class exists because the only strict=True caller wraps snapshot emission in a broad except Exception (the #1788 "snapshot emission must never break mount" posture): runtime.py now re-raises this one deliberate rejection past that wrapper, so the DEBUG failure is loud on the REAL mount path too — not only when _capture_snapshot_state is called directly (review finding). Deliberately scoped to strict-mode ONLY so the two other callers of the same method are unaffected: the rendering JIT pipeline (_is_serializable/get_state(), which intentionally lets Model/QuerySet through for template rendering) and the dev-only time-travel debug capture (time_travel.py, which already accepts a lossy snapshot by design for state_before/state_after). 8 new tests in test_state_snapshot_orm_early_validation.py (DEBUG raise, production warn+skip, non-strict callers unaffected, rendering pipeline unaffected, and two real-path dispatch_mount cases: DEBUG propagates out of the runtime wrapper / production mount survives with the ORM key skipped from the signed blob). live_view.pyi (the ADR-023 strict-island stub runtime.py type-checks against) also declares NonPersistableStateError now — mypy resolves module attributes against the .pyi when both it and the .py exist, so the new class was invisible to the strict-island check until added there too (CI catch, not a runtime bug).

  • {% djust_markdown %} (and any custom tag) corrupted a loop/include-scoped dict-field value into a Python-tuple-repr (#2037). The Rust custom-tag dispatch pre-resolves a bare-name argument (block.text) to its value string before handing it to the Python handler; TagHandler._resolve_arg then re-interpreted that already-resolved value as a template token — any value containing = was tuple-split, so the markdown source rendered as a literal ('...', '...') repr (observed in a production chat app on a per-{% for %}/{% include ... with %}-scoped block.text whose text contained =); a dotted value could likewise be re-resolved against the context. _resolve_arg now token-guards its kwarg-split and dotted-lookup heuristics, so a non-token (Rust-resolved) value is returned verbatim; the markdown handler additionally falls back to the raw source string if a positional source ever resolves to a tuple. Root cause reproduced deterministically at the unit, handler, and real Rust-render levels — the report's streaming/loop-cache hypothesis was a red herring. New cases in TestResolveArgNoDoubleResolution, TestMarkdownHandlerResolvedSource, and TestRealPathLoopScopedMarkdown.

  • dj-virtual overlapping/garbled content after SPA navigation (#2033). SPA-style navigation (same page, no reload) can reuse the SAME physical [dj-virtual] container node across a view/data-source change instead of remounting. Client virtualization state lives in a WeakMap keyed on the container node's identity, and the never-removed shell/spacer survive the server morph, so nothing tore the old virtualization down: viewing a virtualized thread then navigating to a small non-virtualized thread left a leftover row from the previous thread rendered at the shell's stale translateY, overlapping the new view's real rows (a full page reload fixed it). Three client gaps closed in 29-virtual-list.js: structureIntact() now fails closed on identity change (dj-virtual removed / value changed / dj-id changed) so a repurposed container is never treated as still virtualized; a new reapStaleVirtualLists() discovers tracked containers via the never-removed data-dj-virtual-shell marker (the WeakMap isn't iterable) and tears down any whose identity changed — WITHOUT restoring the old item pool (the morph already authored the new content), re-virtualizing fresh if the container still carries dj-virtual; and absorbLooseChildren() no longer accumulates loose children across an identity change, so the previous thread's rows and the new thread's rows can't merge into one pool. The normal same-thread self-heal (#1988/#1989 absorb path) is unchanged — the fix is scoped to the identity-CHANGE case. Regression coverage: 3 cases in tests/js/dj-virtual-teardown-2033.test.js (attr-loss teardown, dj-id-change re-virtualize-fresh with no cross-source merge, and a same-thread absorb regression guard), with a gate-off sentinel that turns the two identity-change cases RED when the teardown is disabled.

Changed

  • python/djust/tests/ is now a blocking CI gate + covered by the pre-push hook (#2034). The #2032 soak step (continue-on-error) ran green on the main push-CI run at 72d78601, satisfying the #1534 green-on-runner-first precondition, so it is promoted to a blocking step in the python-tests job — a failure now fails python-tests, which is in the test-summary AND-condition (#1713), so it gates the merge. The pre-push pytest hook gains python/djust/tests/ too, matching CI (the explicit tests/ python/tests/ paths override pyproject's testpaths, which is how this ~4000-test dir was historically absent from both surfaces). Empirically validated: pytest python/djust/tests/ -n auto → 4066 passed, 3 skipped. No runtime/behavior change.
  • CI now covers python/djust/tests/, and the stale setattr-chokepoint security guard is green again (#2032). The gating Python job ran pytest tests/ python/tests/ — explicit paths that override pyproject's testpaths — so a large suite (V008 checks, mount-chokepoint structural pins, restore tests) ran in neither CI nor the pre-push hook. As a result the TestSetattrChokepoint CWE-915 mass-assignment guard had been RED on main undetected (a sanctioned DynamicLiveView function-view-decorator setattr site drifted off the whitelist's pinned line numbers). The whitelist is re-verified and corrected, and python/djust/tests/ is added to CI as a non-gating soak step (continue-on-error, per the #1534 green-on-runner-first rule); promotion to a blocking gate + pre-push coverage is tracked in #2034. No runtime/behavior change.

Security

  • Cookie-derived theme values are sanitized before debug logging (CWE-117 log injection; CodeQL py/log-injection #2563–#2569). ThemeManager.get_state logged the four client-set theming cookies (djust_theme / _preset / _pack / _layout) to two logger.debug calls unsanitized. The calls already used %s parameterization (not a format-string bug), but a cookie value carrying a CR/LF could still forge log lines or poison SIEM parsers — the values are attacker-controlled and reached the log verbatim. Each cookie-derived value is now routed through sanitize_for_log (the CodeQL-recognized barrier in djust._log_utils, which strips CR/LF/control chars), matching the pattern already used across runtime.py, sse.py, and the theming gallery. Behavioral regression test in python/djust/tests/test_theming_log_injection.py drives get_state with CRLF-laden cookies and asserts no rendered log record carries a raw newline (gate-off verified: reverting the sanitization makes the forged [CRITICAL] line reappear). No runtime/behavior change to theme resolution.

[1.1.0rc6] - 2026-07-03

Fixed

  • VDOM stale-baseline reload on reconnect/state-restore (#1977). After a WebSocket reconnect / state-restore between events (laptop sleep, network blip, server restart), ViewRuntime.dispatch_mount created a fresh view whose Rust diff baseline was primed from a render that did not match the client's pre-disconnect live DOM. The first post-restore event was then diffed against that stale baseline, landing SetText patches on the wrong node (often a bare #text node) — 2/N patches failed → an html_recovery reload/flicker. The restore mount now sets view._force_full_html = True, so the first post-restore render emits a full html_update frame: the client morphs wholesale and the Rust baseline is re-primed to the live DOM, so no stale-baseline diff can reach the client. One guard at the converged mounted_from_restore seam covers both restore mechanisms (session-saved-state
    • signed-snapshot HMAC) and all transports (WS handle_mount is a thin shim to dispatch_mount; SSE + runtime use it directly). Scoped to the restore path — a fresh mount still renders a normal VDOM patch (no perf regression). Regression coverage: 3 cases in python/djust/tests/test_stale_baseline_restore_1977.py.
  • Converged the WebSocket and runtime async-callback dispatch onto one shared helper (#2020). #2016 fixed a #1646 parallel-path drift — an async @background handler silently failed on the converged runtime path because ViewRuntime._execute_async_task routed every callback through sync_to_async (raising TypeError: sync_to_async can only be applied to sync functions) while the WS consumer's _run_async_work awaited async callbacks directly — but it fixed it by copying the coroutine-dispatch branch, leaving two identical copies primed to re-drift. This extracts the dispatch into one run_async_callback in mixins/async_work.py that both transports now delegate to, so the sync/async handling can never diverge again. New test_async_dispatch_parity_2020.py: 4 behavioral cases over the shared helper (including the async-def gate-off sentinel) + 3 structural pins asserting both paths call the helper, neither keeps its own iscoroutinefunction(callback) branch, and the helper is the single definition in the package.

Added

  • CI: check-changelog-tagged-sections pins already-shipped CHANGELOG sections against the newest release tag (#2028). A 3-way merge of CHANGELOG.md across branches that diverged around a release cut can silently rewrite an already-shipped ## [X.Y.Z] section with ZERO conflicts — git's diff3 has no notion that a version heading is immutable. The v1.1.0rc5 consolidation incident moved ~150 lines of unreleased content into the already-tagged [1.1.0rc4] body, falsely claiming unshipped work had gone out; neither check-changelog-test-counts nor check-adr-status catches it (both check the diff's own claims, not whether a shipped section changed at all). New pre-commit hook scripts/check-changelog-tagged-sections.py finds the newest release (top-most ## [X.Y.Z] section whose vX.Y.Z tag exists) and asserts every section below it is byte-identical to that tag's frozen snapshot. Pinning against the newest tag (not each section's own tag) is required because this repo's rolling-rc sections keep accumulating entries after their own rc tag — a section is frozen once superseded, not at its own tag. Dogfooded clean against 119 shipped sections; empirical canary (#1459) confirms it catches a spurious injection into [1.1.0rc4]. 3 new cases in tests/test_changelog_tagged_sections.py (gate-off sentinel + non-tautology guard).

[1.1.0rc5] - 2026-07-03

Security

  • The template getattr sidecar now enforces the serialization floor across every access path — closes a denylisted-field leak (password / is_superuser / is_staff / get_session_auth_hash) to the client + a worker DoS (#1986 review, ADR-024). djust's serialization floor (_ALWAYS_EXCLUDED_FIELDS, SECURE_DEFAULTS Pattern 1 / #1868) strips sensitive fields from the eager state dict, but the Rust engine's lazy sidecar getattr walk — the fallback that resolves {{ obj.attr }} on live model instances — consulted no denylist, so sensitive fields rendered straight into client HTML. The PR #1986 adversarial review found this was mostly pre-existing/shipped (request-scoped user has always been sidecar-only) with one variant that this release's raw-model retention would have newly introduced, across seven entangled vectors — reducible to two mechanisms: a floor field read off a raw model during the getattr walk (1, 2, 4, 6), and a raw model __dict__-dumped during value conversion (3, 5, 7): (1) direct {{ user.password }}; (2) manager/queryset traversal {{ x.groups.first.user_set.first.password }} — a model returned by an auto-called manager method was unwrapped; (3) {% for u in qs %}{{ u.password }}{% endfor %} — queryset items went through the Rust FromPyObject __dict__ bulk-dump (crates/djust_core/src/lib.rs), which filtered only _-prefixed keys, so it dumped password for any model converted to a value; (4) {{ obj._meta }} — a _-prefixed getattr that segfaulted the worker (Options extraction) + {{ obj._meta.db_table }} schema disclosure; (5) .values() / .values_list() projections — {% for x in qs.values %}{{ x.password }} / {{ qs.values.first.password }} — which yield raw dict/tuple rows with no model identity, so .first/.get/index/iteration each returned an unfiltered row; and (6) a non-model intermediary object placed in the context (a "presenter"/view-model) exposing a raw model/manager/queryset — {{ presenter.user.password }}, {% for x in presenter.qs %}{{ x.password }}, and a model method returning a model ({{ obj.get_related.password }}, whose Rust-auto-called result never re-enters a Python proxy); and (7) a raw list/tuple of models reached via a non-model intermediary — {% for x in presenter.items %}{{ x.password }} — whose elements reach the Rust FromPyObject Vec<Value> extraction as raw models and hit the __dict__ bulk-dump. Fix: _SidecarModelProxy + _SidecarQuerySetProxy (python/djust/serialization.py) wrap every model/manager/queryset entering the sidecar and transitively protect everything they return (_protect_sidecar_value), refusing exactly what the eager path (DjangoJSONEncoder) refuses — the same field floor/allowlist via _field_is_serializable and the same sensitive-method set (extracted to shared _SENSITIVE_MODEL_METHODS / _SENSITIVE_MODEL_METHOD_PREFIXES constants so the two paths can't drift, #1646). _-prefixed names are refused outright (Django parity — closes vector 4). Model→value conversion now routes through a __djust_serialize__ hook that returns a denylist-filtered dict/list (via normalize_django_value, the same serializer the eager path uses) instead of the __dict__ bulk-dump — closing vector 3 while keeping {% for %} field access working. .values()/.values_list() projections are refused wholesale in the sidecar (vector 5) — their rows carry no per-field floor and every access path (.first/index/iteration) would leak; they never rendered in the sidecar auto-call walk before this release anyway (auto-call is new), so refusing is fail-closed with zero regression (precompute projected rows in get_context_data(), where the eager floor applies). And because Python-side proxies alone cannot cover a raw intermediary object (no proxy __getattr__) or a Rust-auto-called method result, the Rust resolve walk gained a single protect_sidecar chokepoint (crates/djust_core/src/context.rs) that routes every just-materialized value — after both getattr and the auto-call — through _protect_sidecar_value, so a model/manager/queryset is floor-wrapped however it was reached (vector 6). And the value-conversion root — FromPyObject for Value (crates/djust_core/src/lib.rs) — now routes any raw Django model through normalize_django_value (the denylist serializer) instead of the __dict__ bulk-dump, so a raw model reaching a Value via a list/tuple/dict container is floor-filtered too (vector 7). These are the two durable chokepoints — the getattr-walk protect_sidecar and the conversion-root model routing — so the fix is one authority per mechanism (#1646), not N surface-path patches; a future surface variant of either mechanism is already covered. The floor is not gated on the template_auto_call kill-switch. Legit access (safe fields, get_full_name, managers/.count, relations, {% for %}{{ g.name }}, safe fields reached through a presenter object, and a raw list of models) is unaffected. 28 tests in test_template_auto_call_1985.py (TestSidecarSerializationFloor covers all seven vectors + legit preservation + a proxy unit-pin) — gate-off verified (neutering the wrapping, the transitive protection, the _-prefix refusal, the projection guard, the Rust protect_sidecar chokepoint, or the FromPyObject model routing makes the corresponding leak test RED). Field-type-based exclusion (always-drop BinaryField, encrypted-field types) is a follow-up hardening of both paths (#1987).

  • TYPE-based serialization floor — always-drop BinaryField + encrypted-field types + a configurable sensitive_field_types list, on both client-bound paths (#1987, follow-up to #1986). The #1986 floor drops sensitive fields by NAME (password / is_superuser / is_staff + DJUST_SENSITIVE_FIELDS + per-model djust_exclude_fields). #1987 adds a complementary, name-independent axis that drops a field whose type should never reach the client: BinaryField (raw bytes) unconditionally; best-effort encrypted-field types (an MRO class name case-insensitively containing encrypted/fernet — django-encrypted-fields / django-fernet-fields and similar — no hard dependency, excluded fail-closed, with a one-shot DEBUG breadcrumb per class so a heuristic false-positive is diagnosable rather than a silent vanish); and any class named in the new LIVEVIEW_CONFIG['sensitive_field_types'] (a project-configurable list, empty by default; case-exact). FileField/ImageField are explicitly NOT excluded — they serialize a URL, the intended payload. Both client-bound paths — the eager encoder (DjangoJSONEncoder._serialize_model_safely) and the lazy template sidecar proxy (_SidecarModelProxy.__getattr__) — call the SAME authority _field_type_is_excluded (sidecar via _field_type_excluded_for), so the name floor's #1646 parallel-path lesson holds for the type floor too: one authority, no drift. 18 tests in python/djust/tests/test_field_type_exclusion_1987.py (authority unit tests + eager-path + sidecar-path + configured-type + case-insensitive/false-positive/one-shot-breadcrumb + gate-off sentinels — reverting either wired check makes the BinaryField-leak test RED). See SECURE_DEFAULTS Pattern 1.

  • ViewRuntime.dispatch_mount gained the signed state-snapshot HMAC restore + emit WebSocket has — byte-identical caps — and it goes LIVE for the SSE mount path (#1913, ADR-022 Iter 3 Phase 3.1). The opt-in state-snapshot feature (enable_state_snapshot = True) restores a view's public state from a client-echoed payload on back-navigation in lieu of mount(); the payload is a server-signed TimestampSigner blob (CWE-345 → CWE-915) whose restore is the SECURITY BOUNDARY. The runtime mount path — which is the SSE mount path since Iter 1 (#1887) — previously had NO snapshot restore at all, so converging SSE onto it without porting the restore would either drop the feature for SSE or (worse, if added carelessly) open an unsigned-snapshot injection vector. dispatch_mount now ports the WS restore VERBATIM (websocket.py:2491-2587): the same unsign_snapshot(blob, slug=view_path, sid=session_key) HMAC binding (a snapshot signed for view A / session S1 / older than DJUST_STATE_SNAPSHOT_MAX_AGE does NOT restore), the same size cap (64 KB verified inner JSON), keyset cap (256 keys), dict-type cap, the DJUST_STATE_SNAPSHOT_ENABLED operator master-switch, and the _should_restore_snapshot(request) view-level veto. The session key for the sid binding is sourced from request.session and stamped on the view (_django_session_key) so the runtime/SSE path validates the SAME session binding the WS path does. The matching emit (sign_snapshot on the mount frame, websocket.py:2754-2792) is also ported, opt-in only. Gated enable_state_snapshot — default views never restore or emit (#1552); for SSE the restore is a no-op unless the view opts in AND a snapshot is present. WS UNTOUCHEDhandle_mount keeps its own copy until the Phase 3.3b flip; RUNTIME_OWNED_VERBS / WS routing / handle_mount_batch are unchanged (websocket.py has no diff). New suite python/djust/tests/test_runtime_mount_state_restore_1913.py — doc-claim-verbatim HMAC-caps TDD (#1046): a snapshot signed for a different view / a foreign session / past the TTL / forged-unsigned / tampered / oversized / over-keyset / vetoed does NOT restore via the runtime path (state stays at the mount() default), each with a gate-off sibling (#1468). Gate-off verified: skipping the slug cap in unsign_snapshot makes the cross-view restore wrongly succeed (RED); gating the runtime restore/emit/hook-redirect off makes the corresponding tests RED. The existing WS pins (test_state_snapshot_signing.py, test_ws_reconnect_state_1465.py) stay green.

  • ViewRuntime gained a transport.recheck_event_auth(view) hook for opt-in per-event auth re-check (reauth_on_event, #1777 threat-model T3), and it goes LIVE for SSE (#1905, ADR-022 Iter 2 Phase 2.3a). Auth runs once at mount and the mount-time principal is cached on the session, so a user who logs out / loses a permission mid-session would keep dispatching events on the open connection until they reconnect. The bespoke WS handle_event already re-checks per-event auth when LIVEVIEW_CONFIG['reauth_on_event'] is set + the view requires auth (websocket.py:3193-3222), but the runtime had no equivalent — so the SSE event path (converged onto the runtime since Iter 1, #1887) had NO mid-session deauth gate at all. New Transport.recheck_event_auth(view) -> bool (default-True = no re-check) wired into ViewRuntime._dispatch_event_inner at the SAME point WS does — after the view-mounted check, BEFORE the actor branch and the handler. WSConsumerTransport replays the WS bespoke logic verbatim (re-resolve the user from the scope session via channels.auth.get_user, reflect onto view.request.user, re-run check_view_auth_lightweight; on failure navigate to the login url + close(4403)). SSESessionTransport re-checks against the LIVE event-POST request (session._event_request, stamped by the /event/ + /message/ endpoints just before dispatch — the current POSTer's request.user, not the stale mount request) — covering the case owner-binding (Finding #24) cannot: a still-authenticated, still-owning POSTer whose permission was revoked mid-session — and on failure sends an auth-error frame + ends the stream. Both fail-safe (any error skips the re-check, never breaks an event) and gated on reauth_on_event + login_required/permission_required (default views pay nothing). #291 multiplexed-path care: the runtime clears view_instance UNCONDITIONALLY on a False return (the state change that closes the security gap — no later frame on the session dispatches against the deauthorized view); the transport-terminating close is OWNED + gated by the hook (events are not batched today — mount_batch is mount-only — but the close stays gateable if events are ever collected, matching the WS bespoke view_instance = None after close). LIVE for SSE; DORMANT for WS — WS events still run on the bespoke _handle_event_inner (which keeps its own inline re-check) until the Phase 2.3b flip; RUNTIME_OWNED_VERBS / WS routing are UNTOUCHED, websocket.py's reauth block is unchanged. New suite python/djust/tests/test_runtime_reauth_async_1905.py (TestSSEReauthOnEvent, TestReauthHookShape291, TestWSReauthAdapterPort): real-SSE end-to-end (mount with a permission, POST with it revoked → refused + error frame + stream end + view_instance cleared; still-authorized → renders; default-OFF → no re-check) + the #291 shape (state cleared even when the close is gated, via a fake transport) + the WS-adapter port. Reproduce-first + gate-off (#1468) verified: gating the recheck off makes the deauthorized SSE event wrongly render (RED) and the #291 state-clear assertion fail (RED). test_event_reauth_1777 (the bespoke WS path) stays green.

  • Closed a latent object-permission gap (IDOR-class) in ViewRuntime.dispatch_mount before it could go live (#1885, ADR-022 Iter 0). The WebSocket handle_mount enforces the ADR-017 post-mount object-permission check (check_object_permission), but ViewRuntime.dispatch_mount did not — so a view whose has_object_permission() returns False (or whose get_object() denies) would have mounted, rendered, and sent the denied object to the client through the runtime path. The gap was not yet exploitable (dispatch_mount has zero production call sites today), but Iter 1 of the ViewRuntime convergence (routing SSE through the runtime) would have made it live. The runtime mount now routes through the SAME shared enforce_object_permission chokepoint the other transports use (runtime.py, mirroring websocket.py:2554-2573), placed AFTER mount() (so get_object() can read URL-derived attrs) and BEFORE handle_params + render (so a denied object is never rendered or sent). Fail-closed; a no-op for views without a custom get_object (behavior-preserving). Reproduce-first + gate-off (#1468) verified: a denied view mounts + leaks its rendered HTML before the fix, emits only a permission_denied error frame after. New cases in TestDispatchMountObjectPermission (python/djust/tests/test_transport_behavioral_parity.py).

Added

  • Template callable auto-call — Django parity in variable resolution (#1985, ADR-024). Django's template engine auto-calls callables during variable resolution; djust's Rust engine did not, and the divergence was silent: {{ request.user.get_full_name }} rendered the literal <bound method AbstractUser.get_full_name of <User: jordan>> and {{ workspace.memberships.count }} rendered empty (DJUST_LESSONS gotcha #7, hit in downstream production builds). The bug class was #1646 parallel-path drift — the eager serialization path already auto-called (codegen.py get_*/all/count/exists; serializer properties + explicit get_*), but the lazy sidecar getattr walk (Context::resolve, crates/djust_core/src/context.rs) — the path serving request-scoped objects (user) and reverse relations/managers — never invoked callables, and the un-called bound method fell to the FromPyObject str() catch-all. The walk now implements Django's exact Variable._resolve_lookup semantics at every segment (root, mid-path, final): no-arg call0(); do_not_call_in_templates → used as-is (Model classes, Choices enums); alters_datanever called, renders empty (the data-destruction guard — {{ user.delete }} cannot destroy data); TypeError from the call runs the inspect.signature(...).bind() probe (args-required → empty; internal TypeError propagates); other exceptions propagate as render errors. Explicit-context models are now also kept raw in the sidecar (the eager dict wins every hit; the raw model serves only nested paths the dict lacks), so {{ workspace.memberships.count }} works for explicitly-assigned models too — not just request-scoped ones. The pre-existing eager auto-call sites gained the same guards (codegen.py ×2 generated-code sites, serialization.py::_add_safe_model_methods). Observability: a debug-only, one-shot-per-path warning fires when an auto-call is bound to a Manager/QuerySet — in a LiveView that is a DB query per re-render (per WebSocket event), so precompute in get_context_data() on hot paths. Kill-switch: LIVEVIEW_CONFIG["template_auto_call"] (default True); False restores the pre-ADR no-call walk. 16 doc-claim-verbatim tests in python/djust/tests/test_template_auto_call_1985.py (one per semantics row + both reported symptoms through the real render path + side-effect sentinels + kill-switch gate-off). See docs/adr/024-template-callable-auto-call.md.

  • LiveView.set_changed_keys(keys) — public escape hatch to force a re-render after an in-place mutation of nested state (#1981). djust's change detection uses a fast identity + shallow-fingerprint snapshot (_snapshot_assigns) that deliberately does NOT deep-copy state (~100× faster than copy.deepcopy), so an in-place mutation of a nested container — self.rows[0]["cards"].append(x), self.columns[0]["cards"].pop() — shares the previous snapshot's object and is invisible, producing zero patches (the Phoenix-style immutability trade-off, documented in state-primitives.md). The _snapshot_assigns fingerprint-truncation warnings and docstring already advised calling self.set_changed_keys({...}), but no such method existed (it would AttributeError). This adds the method to RustBridgeMixin (inherited by LiveView): it marks the given keys changed and sets _force_full_html to force the re-render the auto-skip would otherwise drop. _changed_keys and _force_full_html are now in _FRAMEWORK_INTERNAL_ATTRS (excluded from the assigns snapshot), so assigning self._changed_keys directly is genuinely ineffective — previously it perturbed the snapshot fingerprint and triggered a render by side effect rather than by the sanctioned mechanism (caught by the PR #1982 adversarial review). The _force_full_html skip-bypass is honored — and the flag consumed after the render — on every live path: the runtime event spine, the WS deferred-activity path, and the WS tick loop (the latter two gained the guard/reset in this PR, the #1646 parallel-path sweep). Accepts a single attr name or an iterable; calls accumulate within an event. Prefer an immutable update (self.rows = [...]) where a targeted diff matters — because the aliased previous state can't be diffed, set_changed_keys forces a full re-render. Verified on the production ViewRuntime.dispatch_event path (not just LiveViewTestClient, which bypasses the skip); gate-off (#1468): neutering the method turns the in-place-mutation render test RED. See docs/state-management/STATE_MANAGEMENT_API.md.

  • Strict type enforcement on components/rust_handlers — the ADR-023 ratchet is COMPLETE (M4g, final module). This flips the LAST lenient holdout — the Rust template-engine component tag-registration shim (~193 inline/block render() handlers that parse untyped Rust-engine arg lists ["key=val", ...] into object-valued dicts and emit component HTML) — from the lenient mypy default to a strict island ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). This module was the sole sanctioned lenient exception (the genuinely-dynamic Rust-FFI boundary; an earlier attempt, M4b-1, found ~344 errors and documented it as intractable). It flipped clean with ZERO new # type: ignore (the only one in the file is the pre-existing _rust [import]). Two patterns did the work: (a) a typed module-level _safe() wrapper that casts Django's @keep_lazy-decorated (untyped → Any) mark_safe to str, absorbing the ~200-strong no-any-return cascade across every handler return without ignores; (b) inline cast(...) / str(...) (runtime no-ops) at each int()/float()/dict-key/attribute site of the kw.get(...) -> object cascade, plus a handful of explicit var: float/list[...]/dict[...] annotations. Render output is proven byte-identical — a deterministic-UUID parity harness rendered every handler against the pre-flip version and confirmed 382 outputs across all 193 handler classes are identical bytes (the cast/str coercions are runtime no-ops; the only str() wraps that touch lookup keys were converted to cast to guarantee key identity). mypy python/djust stays GREEN (822 files) with djust.components.rust_handlers strict; gate-off-verified (#1468) — a wrong-typed int return injected into a handler (ModalHandler.render, declared str) turns the gate RED ([return-value]), reverting restores GREEN. With M4g, no lenient exception remains in the components/ package — the global lenient default now parks only legacy non-components modules. Full suite 8604 passed / 0 failed. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the scaffolding/ + template_tags/ + theming/gallery/ subpackages — 20 modules (ADR-023 M4e, group 2). The next ratchet step flips three more subpackages from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans: the CRUD scaffolding generator (scaffolding/gen_live/gen_live_templates/generator/templates: the JSON/interactive schema-to-LiveView+admin code generator); the Rust-engine custom template-tag handlers (template_tags/{% url %}/{% static %}/{% djust_pwa %}/{% templatetag %}/{% dj_flash %}/{% djust_markdown %}/{% djust_client_config %}/{% live_render %} registered with the Rust renderer; this is the underscore template_tags/ package, distinct from the Django-engine templatetags/ package already flipped in M4d group 1); and the theme-gallery / component-storybook surface (theming/gallery/views the gallery/editor/diff + storybook DEBUG/staff-gated views, context the example-context + token-serialization builders, component_registry, urls, storybook). None of the three subpackages has a tests/ dir, so the ratchet completes each in one PR with no test sub-package to defer. Annotated with real types (params + returns — not Any cosmetics): HttpRequest/HttpResponse on the gallery views, list[dict[str, Any]] on the example builders, Callable[[Type[TagHandler]], Type[TagHandler]] on the @register decorator factory. Render output is byte-identical — the SafeString/HTML boundaries (format_html in flash, escape in markdown, Template.render in pwa, reverse in url, static in static, _client_config_html in client_config, the dynamic component .render() in component_registry) return Any under the lenient global config (Django + the cross-island live_tags._client_config_html are seen as untyped), so each is coerced with str(...) at the boundary to satisfy warn_return_any WITHOUT changing the returned (already-safe) HTML. One real type fix: scaffolding/generator.py list_display_fields annotated list[str] (was an un-annotated [] flagged var-annotated). mypy python/djust stays GREEN (822 files) with all 20 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typed int return injected into template_tags/url.UrlTagHandler.render (declared str) turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the theming/themes/ theme-definition subpackage — 66 modules (ADR-023 M4f). The next ratchet step flips the per-theme definition subpackage from the lenient mypy default to a strict island via a single glob [[tool.mypy.overrides]] module = ["djust.theming.themes.*"] (ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any) — mirroring the djust.security.* glob pattern, so a new built-in theme file added to this directory is strict-by-default with no further pyproject edit. The subpackage is 63 per-theme data modules (default / nord / dracula / catppuccin / tokyo_night / gruvbox / … — each a flat set of module-level ColorScale/ThemeTokens/ThemePreset/DesignSystem/ThemePack literals, zero functions), the dependency-free re-export hub _base, the package __init__ (pure re-exports), and the deprecated _legacy module (the Theme/THEMES dataclass API kept for backward compat). 64 of the 66 modules were already strict-clean (data + re-exports), so M4f is mostly a config-flip; the only annotation work was on _legacy._DeprecatedThemesDict's nine untyped dict overrides (__getitem__/__contains__/get/items/keys/values/__iter__/__len__) — annotated to match the dict[str, Theme] superclass signatures (the three view methods declare -> Any for the un-nameable concrete dict_items/dict_keys/dict_values return types, the established codebase pattern). No real bugs found — the theme modules are pure data and _legacy's overrides were behaviorally correct, just unannotated (logic byte-identical; deprecation-warning behavior unchanged). mypy python/djust stays GREEN (822 files) with theming/themes/* strict; gate-off-verified (#1468) two ways — a wrong-typed str return on _legacy._DeprecatedThemesDict.__len__ (declared int) turns the gate RED ([override] + [return-value]), AND an untyped def injected into a theme-DATA module (nord.py) turns it RED ([no-untyped-def]), proving the glob covers the data modules and not just _legacy; reverting either restores GREEN. Behavior is byte-identical (annotations are runtime no-ops); full suite 8604 passed / 0 failed. Note: the top-level theming/ modules are already strict (M4c part 3), but mypy's djust.theming.* glob matches only direct children, not the deeper djust.theming.themes.X submodules, so this subpackage needed its own override entry. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the loose top-level modules + backends/ + db/ — 22 modules (ADR-023 M4e, group 1). The next ratchet step flips the independent loose top-level modules and the two leaf subpackages from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the loose top-level modules (apps DjustConfig, audit_ast AST security-audit walker, audit_live runtime auditor, bug_capture harness, checks_css_proposal proposed CSS system-checks, hooks lifecycle registry, hot_view_replacement HVR engine, state_backend/template_backend back-compat re-export shims, template_filters helpers, time_travel recorder, utils shared helpers + BackendRegistry, __main__ entry point) and the two leaf subpackages: the presence backends (base, memory, redis, registry, __init__) and the PostgreSQL LISTEN/NOTIFY bridge (decorators, exceptions, notifications, __init__). Annotated with real types (params + returns — not Any cosmetics): db/decorators.notify_on_save.decorate typed type[models.Model] so _meta/label resolve, with narrow # type: ignore[attr-defined]s on the dynamic _djust_notify_channel/_djust_notify_receivers introspection attrs stashed on/deleted from the decorated model class; the signal receivers _on_save/_on_delete annotated (sender: type, instance: Any, **_kw: Any) -> None. Four genuine clean-up fixes (the kind strict-flips surface, ADR-023, all behavior-preserving): backends.registry.get_presence_backend now cast(PresenceBackend, _registry.get()) mirroring the already-strict state_backends.registry pattern (the generic registry returns Any); backends.redis.RedisPresenceBackend.count wraps the untyped zcount Any-return in int(...); db.notifications._import_psycopg gained its -> tuple[Any, Any] return; and db.notifications._dsn_from_url's URL-field loop variable was renamed (valdsn_val) to stop colliding with the earlier str-typed parse_qsl loop var so the mixed str | int | None field tuple type-checks. mypy python/djust stays GREEN (822 files); gate-off-verified (#1468) — a wrong-typed int return in backends.registry.get_presence_backend (declared PresenceBackend) turns the gate RED ([return-value]), reverting restores GREEN. Behavior is byte-identical (annotations + the four wraps/rename are runtime no-ops); full suite 8604 passed / 0 failed. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the theming/templatetags/ + theming/management/ subpackages — 8 modules (ADR-023 M4e, group 3). The continuation of the theming ratchet: M4c (part 3) made the theming MACHINERY strict but explicitly deferred the user-facing render surface (the templatetag modules — theme_components was the heaviest at ~51 errors — plus the management command). This group finishes theming by flipping those deferred leaves from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the four template-tag modules (theme_components — the ~26 themed component tags theme_button/theme_card/theme_alert/theme_input/theme_modal/theme_table/theme_nav/etc.; theme_pages — the auth/error/utility page-fragment tags theme_login_page/theme_404_page/theme_maintenance_page/etc.; theme_tags — the theme_head/theme_css/theme_switcher/theme_preset/theme_mode accessors + the shared build_theme_head_context builder; theme_form_tagstheme_form/theme_form_errors/get_css_prefix) and the djust_theme management command (tailwind-config / export-colors / list-presets / shadcn-import-export / init / create-theme / validate-theme / create-package / check-compat / marketplace-info subcommands). These tags RENDER theme components into pages, so their mark_safe/format_html return values are annotated SafeString (the HTML-safe boundary) and context/request/form params get Context/HttpRequest | None/BaseForm; output is byte-identical. The management command uses the established CommandParser/*args: Any, **options: Any shape mirroring djust_setup_css/djust_doctor. Four real type fixes to clean the islands (the kind strict-flips surface, ADR-023): theme_components.theme_progress annotates percentage: float (the min(100, (int(value)/int(max))*100) reassignment is a float; the = 0 seed inferred int[assignment]); theme_tags.theme_framework_overrides narrows the format_html result through a str local at the unstubbed-django boundary ([no-any-return]); the three _css_prefix() helpers + theme_pages._csrf_token_value wrap the untyped get_theme_config().get(...)/get_token(...) boundary in str(...); and djust_theme.handle_marketplace_info reads the required-positional mp_theme_name via subscript (not .get()) so it stays non-Optional for the themes_dir / theme_name Path division + get_component_coverage(str, ...) call ([operator]/[arg-type]). mypy python/djust stays GREEN (822 files) with all 8 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typed int return in theme_pages._css_prefix (declared str) turns the gate RED ([return-value]), reverting restores GREEN. Behavior is byte-identical (annotations + the str(...) boundary coercions are runtime no-ops) apart from the four genuine fixes above; full suite 8604 passed / 0 failed (1878 theming tests green). This completes theming/ except the optional theming/gallery subpackage, which remains for a continuation batch. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the pwa/ + optimization/ + tenants/ + observability/ subpackages — 31 modules (ADR-023 M4d, part 2). The next ratchet step after M4c (theming/ + admin_ext/) flips every non-test module of these four optional-extra subpackages from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the PWA layer (mixins PWAMixin/OfflineMixin/SyncMixin, storage offline backends + OfflineAction/SyncQueue, sync SyncManager/ConflictResolver, manifest, service_worker, utils), the optimization layer (fingerprint StateFingerprint/SectionCache/IncrementalStateSync, codegen serializer code-gen, query_optimizer select/prefetch analysis, cache SerializerCache, __init__), the multi-tenant layer (resolvers, managers TenantManager/TenantQuerySet, backends redis/memory presence, middleware ContextVar tenant binding, mixin TenantMixin/TenantScopedMixin, audit, security, models, __init__ — annotations only; tenant-isolation logic byte-identical), and the observability layer (views localhost-gated endpoints, middleware localhost gate, sql/timings/log_handler/tracebacks capture buffers, dry_run side-effect blocker, registry, urls, __init__). Annotated with real types (params + returns — not Any cosmetics), using the established mixin-collaborator pattern (# type: ignore[misc] on cooperative super().get_context_data()/dispatch() calls mirroring wizard.py/tenants; TYPE_CHECKING-only push_event/sync_queue stubs on the PWA mixins documenting the co-mixed-LiveView contract) and a narrow # type: ignore[import-untyped] on dry_run's lazy import requests (a known-stub package mypy won't silence via ignore_missing_imports). Two real bugs fixed to clean the islands (the kind strict-flips surface, ADR-023): pwa.storage.OfflineAction.id widened to Union[str, int] (callers forward an int model pk as obj_id; the SyncQueue action-id params widened to match), and pwa.mixins.delete_offline now passes the required OfflineAction(data={}) — omitting it raised TypeError at runtime on every call (a guaranteed crash in an untested path). mypy python/djust stays GREEN (822 files) with all 31 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typed str return in optimization.fingerprint.StateFingerprint.version (declared int) turns the gate RED ([return-value]), reverting restores GREEN. Behavior is byte-identical (annotations are runtime no-ops) apart from the two genuine bug fixes above; full suite 8604 passed / 0 failed. Remaining for a continuation batch: the pwa/{templatetags,management}-style leaf packages do not exist for these four subpackages, so M4d(2) completes their non-test surface. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the tutorials/, api/, template/, and state_backends/ subpackages — 20 modules (ADR-023 M4d, part 3). The next ratchet step flips four independent transport/render/persistence subpackages from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the declarative guided-tour state machine (tutorials/ — the TutorialStep dataclass + TutorialMixin async tour loop, with if TYPE_CHECKING: declarations for the sibling-mixin surface it cooperates with — push_commands/_flush_pending_push_events/wait_for_event), the opt-in HTTP-API transport (api/ — the @event_handler(expose_api=True) + @server_function dispatch views, the pluggable BaseAuth/SessionAuth contract, the view registry, the OpenAPI schema builder, and the URL wiring), the Rust template engine's Django backend (template/DjustTemplateBackend.get_template/from_string, the multi-line {# #} get_contents loaders, the DjustTemplate rendering pipeline incl. the {% extends %}/{% block %} parser + {% url %} resolver, and the serialize_valueJSONValue serializer), and the LiveView state-persistence backends (state_backends/ — the StateBackend ABC, the in-memory + Redis backends, and the registry). api/ and state_backends/ are security/correctness-relevant — annotations only, logic byte-identical: the _snapshot_assigns/_compute_changed_keys diff, the CSRF/auth/object-perm gates, the rate-limit checks, the msgpack round-trip + identity-guarded cache pop, and the zstd compression path are UNTOUCHED. Three PyO3 methods consumed by the state backends (RustLiveView.serialize_msgpack / deserialize_msgpack / get_timestamp) were added to the _rust.pyi wire-boundary stub (they existed at runtime but were missing from the stub). The only narrow coded # type: ignores are at genuine dynamic edges (the optional-JIT DjangoJSONEncoder = None / _get_model_hash = None import fallbacks in template/rendering.py; the transient None-view health-check probe entry in state_backends/memory.py); cast(...) is used at the Django/zstd/Rust unstubbed-boundary Any leaks, and assert ... is not None narrows already-guarded optionals (the next_start.end() block-parser sites, the _get_compressor() compress path gated by _compression_enabled). mypy python/djust stays GREEN (822 files) with all 20 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typed int return in state_backends/registry.get_backend ([return-value]) turns the gate RED, reverting restores GREEN. Full suite 8604 passed / 0 failed. None of the four subpackages has a tests/ subdir, so the ratchet completes each in a single PR (no test sub-package to defer). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the management/, checks/, auth/, and templatetags/ subpackages + 8 loose top-level modules — 53 modules (ADR-023 M4d, group 1). The ratchet step after the M4c subpackages (mixins/ + admin_ext/ + theming/) flips four more subpackages and the independent loose modules from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans every management command (djust_audit, djust_check, djust_doctor, djust_setup_css, djust_typecheck, djust_gen_live, djust_new, djust_schema, djust_mcp, djust_ai_context, generate_sw, cleanup_liveview_sessions) + the shared _introspect helper; the full Django system-check family (configuration/security/templates/quality/components/integrations/accessibility + the shared utils); the auth layer (the check_view_auth/run_pre_mount_auth/enforce_object_permission security core, the LoginRequiredLiveViewMixin/PermissionRequiredLiveViewMixin, the social_auth_providers context processor, the signup/login views + forms, and the djust_admin plugin + its OAuthProvidersView/SocialAccountsView LiveView pages); all five template-tag modules (live_tags — the big one with {% live_render %}/{% colocated_hook %}/{% dj_activity %} + the lazy-thunk emitter, plus djust_flash/djust_formsets/djust_pwa/djust_tutorials); and the loose modules cli, dev_server, deploy_cli, drafts, http_streaming, session_utils, push, middleware. Annotated with real types (params + returns — not Any cosmetics): SafeString at the mark_safe/format_html boundary; CheckMessage for system-check errors lists + returns; argparse.Namespace/CommandParser for the management commands; ast.* node types (ast.ClassDef/ast.Call/ast.expr/ast.Module) for the AST-based checks; AsyncIterator[bytes] for the ChunkEmitter streaming surface. The only narrow coded # type: ignores are at genuine dynamic edges: the djust.checks setattr re-export (_root.* — the patch-by-path contract from the #1822 monolith split), the djust-admin optional-dependency fallback class (no-redef/assignment), the optional _rust version export (not in the .pyi), the auth-mixin cooperative super().dispatch (provided by the combined View), and the Django model._meta access (no django-stubs). checks/ + auth/ logic is byte-identical — annotations + cast(...)/bool(...) boundary coercions are runtime no-ops; the system-check AST walkers, suppression logic, and the auth precedence (login → permission → custom hook → Django AccessMixin → object-permission) are UNTOUCHED. mypy python/djust stays GREEN (822 files); gate-off-verified (#1468) — a wrong-typed return in a flipped module (templatetags/djust_flash.dj_flashint) turns the gate RED ([no-any-return]), reverting restores GREEN. Full suite 8604 passed / 0 failed. requests (consumed by deploy_cli) joins yaml in the untyped-third-party override. Remaining for a continuation batch: the management/templatetags-adjacent long tail is already covered; the tenants/, backends/, and state_backends/ subpackages + the last few loose modules remain. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the theming/ subpackage — 39 top-level modules (ADR-023 M4c, part 3). The next ratchet step after the components/ batches (M4b) flips every top-level module of the theming system — including its small rust_handlers (unlike the components/ one, this one was already well-typed and is NOT the iceberg) — from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the registry layer (_registry_accessor singleton + registry discovery wiring), the ThemeManager + ThemeState state/session machinery, the CSS generators (theme_css_generator, pack_css_generator, component_css_generator, design_system_css, css_generator), the color machinery (palette, colors, accessibility, high_contrast, presets, design_tokens), the render paths (context_processors, template_resolver, mixins ThemeMixin, components, forms renderer), the build/adapters/tooling (build_themes, shadcn, tailwind, inspector, checks, manifest, loaders, theme_packs, compat, contracts), the apps AppConfig, views, urls, and the leaf _config/_constants/_types/_builtin_presets modules. Annotated with real types (params + returns — not Any cosmetics), using the cast(str, mark_safe(html)) boundary pattern for the theme-component renderers (django's safestring is unstubbed, so mark_safe returns Any; SafeString itself resolves to Any without django-stubs, so a str cast is the honest no-Any-leak shape). mypy python/djust stays GREEN (822 files) with all 39 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typed int return in manager.get_css_prefix ([return-value]) turns the gate RED, reverting restores GREEN. Rendering byte-identical — annotations + cast(...) + int(hue_offset) casts are runtime no-ops; the only behavior-adjacent additions are defensive if self._theme_manager is None: return guards in the four ThemeMixin event handlers (no-ops on the real post-mount path, matching the existing _setup_theme_context guard). Full suite 8604 passed / 0 failed; 1863 theming tests pass (incl. the previously-flaky test_theme_tags_rust_engine_1721, green via #1929's fixture). Remaining theming/ for a continuation batch: the theming/{templatetags,management,gallery} subpackages (the templatetag modules are the heaviest — theme_components ~51 errors — so they're a separate batch). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the mcp/ + contrib/uploads/ + uploads/ subpackages — 14 modules (ADR-023 M4d, group 4). The next ratchet step flips three independent subpackages from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans: the MCP server (mcp/server, mcp/__init__, mcp/__main__ — the AI-assistant introspection/scaffolding tool: create_server() -> "FastMCP" via a TYPE_CHECKING-guarded import so the optional mcp dep is never imported at module load, _ensure_django() -> bool, main() -> None, and the observability-tool returns); the binary-WebSocket-frame upload system (uploads/__init__ — the UploadWriter base + BufferedUploadWriter + UploadConfig + UploadManager, uploads/resumable — the resumable chunk protocol, uploads/storage — the in-memory + Redis UploadStateStore impls, uploads/views — the UploadStatusView HTTP endpoint with HttpRequest/JsonResponse annotations); and the contrib upload-writer adapters (contrib/__init__, contrib/uploads/{__init__,azure,errors,gcs,s3_events,s3_presigned} — the S3 presigned/event, GCS resumable, and Azure block-blob direct-to-storage writers). None of these has a tests/ dir, so the ratchet completes in one PR with no test sub-package to defer. Annotated with real types (params + returns — not Any cosmetics); the only narrow coded edges are: # type: ignore[override] on the legacy write_chunk(self, chunk) adapters (BufferedUploadWriter, GCSMultipartWriter, AzureBlockBlobWriter) — the dropped trailing chunk_index default is an INTENTIONAL, runtime-dispatched part of the UploadWriter contract (_writer_accepts_chunk_index introspects the signature; documented on the base method), and cast(...) narrows at the untyped boundaries (json.loads in uploads/storage, boto3.generate_presigned_url in s3_presigned, requests.Response.text + session.session_key in mcp/server/uploads/views). Two mcp/server observability-tool params dicts inferred homogeneous-then-mutated-with-the-other-type were annotated dict[str, object]. Third-party requests (consumed by mcp/server + contrib/uploads/gcs, no stubs) is marked untyped in the shared ["yaml", "requests"] override. Upload logic is byte-identical — these are security-relevant binary-frame handlers; annotations + cast(...) are runtime no-ops and NO chunk-dispatch, size-cap, or HMAC logic was altered. mypy python/djust stays GREEN (822 files); gate-off-verified (#1468) — a wrong-typed return injected into uploads/storage.delete turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed (413 upload/mcp-related tests pass). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the admin_ext/ subpackage — 13 modules (ADR-023 M4c, part 2). The next ratchet step after the components/ batches (M1 foundation → M2 public-API quartet → M3 dispatch core → M4a loose top-level → M4b-1/2/3 components/) flips the entire Django-admin integration from the lenient mypy default to a strict island ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans every non-test admin_ext/ module: the DjustAdminSite (model + plugin registration, URL generation, app-list / plugin-nav / widget collection), DjustModelAdmin (the list/detail/form/action config + queryset auto-optimization), the plugin system (AdminPlugin / AdminPage / AdminWidget / NavItem), the LiveView-based admin views (AdminIndexView, ModelListView, ModelDetailView, ModelCreateView, ModelDeleteView, LoginView, LogoutView + the admin_login_required wrapper and the _VIEW_REGISTRY plumbing), the AdminFormMixin (FK/M2M option loading, date/time field detection, readonly handling, real-time field validation), the bulk-action progress widget + @admin_action_with_progress decorator, the AdminTailwindAdapter admin CSS-framework adapter, the register/action/display decorators, the DjustAdminConfig AppConfig, the autodiscover package __init__, and the admin template-tag helpers (get_item/get_field/concat/admin_url). Excludes admin_ext/tests/, which stays on the lenient global default. Annotated with real types (params + returns — not Any cosmetics): HttpRequest/Optional[models.Model] on request/obj params, typed class-attr config (list_filter: List[Any], formfield_overrides: Dict[Any, Any], widget_id: Optional[str], …), List[URLPattern] URL builders, and the established mixin-collaborator pattern (request: Any / _model: Any / _model_admin: Any annotation-only attrs on AdminBaseMixin + AdminFormMixin documenting the co-mixed-LiveView contract, plus a # type: ignore[misc] on the cooperative super().as_view() mirroring wizard.py); decorator function-attribute stamping (wrapper.short_description = ...) carries narrow # type: ignore[attr-defined] at the genuine dynamic edge. mypy python/djust stays GREEN (822 files) with all 13 strict and the rest lenient; gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict module (adapters.register_admin_adaptersint) turns the gate RED ([return]), reverting restores GREEN. Behavior is byte-identical — annotations are runtime no-ops; the 95 admin tests (test_admin_basic/test_admin_plugins/test_admin_widgets_per_page/test_bulk_progress + admin checks) and the full suite (8604 passed / 0 failed) confirm no regression. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the mixins/ subpackage — 21 modules (ADR-023 M4c, part 1). The eighth ratchet step (after M1 foundation, M2 public-API quartet, M3 dispatch core, M4a loose top-level, M4b-1/2/3 all of components/) flips the entire mixins/ subpackage — the LiveView mixin layer that composes the public LiveView class — from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans all 21 modules: the small leaf mixins (flash, layout, page_metadata, post_processing, async_workstart_async/defer/assign_async, waiterswait_for_event, model_binding — the dj-model mass-assignment guard, components — the child-component lifecycle), the already-clean leaves (__init__, activity, handlers, navigation, notifications, push_events, sticky, streams), the context/JIT serialization mixins (contextget_context_data/_apply_context_processors/_deep_serialize_dict, jit_jit_serialize_queryset/_jit_serialize_model/_get_template_content), the HTTP request mixin (get/aget/post + the streaming _make_streaming_response/_is_asgi_context), the Rust-bridge / change-detection mixin (rust_bridge_sync_state_to_rust/_initialize_rust_view/_normalize_db_values), and the large template rendering mixin (render/render_full_template/render_with_diff/arender_chunks + the HTML extraction/stripping helpers). Annotated with real types (params + returns — not Any cosmetics), using the established if TYPE_CHECKING: host-attribute-declaration pattern (mirroring streaming.py) for the cross-mixin/host-class surface each mixin cooperates with (get_context_data, _rust_view, template_name, etc.) — a runtime no-op resolved only at type-check time, since a mixin is never instantiated standalone. The only narrow coded # type: ignores are at genuine dynamic edges (the optional-Rust RustLiveView = None / extract_template_variables = None import fallbacks; the event_handler direct-file-import fallback shim; the dynamic component_id/_auto_id attribute sets on the Component | LiveComponent union). rust_bridge/jit change-detection is byte-identical — annotations are runtime no-ops; the _sync_state_to_rust change-detection, the _framework_attrs-class filter conventions, and all id()/value comparison logic are UNTOUCHED (no comparison or filter expression was altered). mypy python/djust stays GREEN (822 files); gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict mixin (template.get_templateint) turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed. The mixins/ ratchet completes in a single PR (no mixins/tests/ sub-package exists to defer). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the FINAL components/ modules — 68 modules (ADR-023 M4b, part 3). The seventh and last components/ ratchet step (after M1 foundation, M2 public-API quartet, M3 dispatch core, M4a loose top-level, M4b-1 core machinery, M4b-2 UI catalog) flips every remaining components/ module — except the deliberately-lenient rust_handlers — from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the component template-tag layer (templatetags/djust_components ~373 fns, _advanced ~84, _forms ~26, _charts ~23 — every Node.render(self, args/content, context) -> SafeString, do_*(parser, token) -> template.Node, and @register.simple_tag/inclusion_tag function, with inclusion_tags correctly typed -> dict[str, Any] since they return a context dict, not HTML), the per-widget mixins/data_table (the DataTableMixin — its ~21 on_table_* event handlers, handle_* override hooks, get_*/_apply_* pipeline, and the safe-arithmetic expression parser), the gallery LiveView surface (gallery/live_viewsGalleryCategoryMixin + 9 category views, with the template_name Liskov conflict resolved by a TYPE_CHECKING-only LiveView base alias; views, examples, registry, context_processors, and the component_gallery management command), the ~24 remaining components/components/* widgets with untyped private-helper params (_render_node/_squarify/_compute_diff/_eval_expression/etc.), the layout/tabs/data/pagination/ttyd/terminal leaves, and the ui/*_simple stateless widgets + ui/dropdown (the over-narrow nav-item dict widened to the honest Any contract per #1108; the optional-Rust import shims — from djust._rust import RustX / RustX = None fallbacks for built-but-unstubbed and declared-but-unbuilt Rust component classes — carry narrow # type: ignore[attr-defined]/[assignment, misc] at the genuine dynamic edge). Annotated with real types (params + returns), using the mark_safe(...) -> SafeString boundary pattern (no Any leak). rust_handlers is deliberately left LENIENT — it is a genuinely-dynamic Rust-bridge registry whose 193 handlers parse untyped Rust-engine arg lists into dict[str, object] (the kw.get() -> object cascade), so strict typing surfaces 344 errors (203 no-any-return + 91 call-overload + …) that would need >200 narrowing changes / # type: ignores with real rendering-behavior risk; the global lenient default is the correct home for it (documented exception in pyproject + this entry). mypy python/djust stays GREEN (823 files) with all 68 strict and rust_handlers lenient; gate-off-verified (#1468) — a wrong-typed return in mixins/data_table ([return-value]) and a dropped annotation in templatetags/djust_components ([no-untyped-def]) each turn the gate RED, reverting restores GREEN. Rendering byte-identical — annotations are runtime no-ops, verified by diffing the rendered HTML of all 7 chart tags, 8 representative widgets (diff_viewer/prompt_editor/heatmap/treemap/json_viewer/org_chart/pivot_table/animated_number), and 8 djust_components simple_tags against the pre-change versions (identical output), plus 143 component/data_table tests passing. Full suite 8604 passed / 0 failed. This completes the ADR-023 components/ ratchet (only the documented rust_handlers exception remains lenient within components/). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the components/ UI catalog + templatetag helpers — 185 modules (ADR-023 M4b, part 2). The sixth ratchet step (after M1 foundation, M2 public-API quartet, M3 dispatch core, M4a loose top-level batch, M4b-1 core component machinery) flips the component UI catalog and small leaf modules from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the full components/components/ widget catalog (146 modules — alert, badge, card, spinner, kanban-adjacent leaves, charts, etc.), the ui/ stateless widgets (8 — spinner, modal, alert, progress, badge, button, card, list_group), the data//forms//layout//gallery//management//ttyd/ leaf packages, the descriptor-based components (descriptors/* — the DEP-002 Accordion/Tabs/Modal/Sheet/Dropdown/Collapsible/Carousel/Tooltip + base), and the 8 deprecated state mixins (mixins/tooltip, tabs, sheet, modal, dropdown, collapsible, carousel, accordion). Annotated with real types (params + returns — not Any cosmetics): typed *_instances class vars (Optional[Dict[str, XState]]), instance_id: str / component_id: str / is_open: bool params, get_*_ctx(...) -> Dict[str, Any] accessors, and render() -> SafeString (mirroring the markdown.py island — mark_safe(...) returns Any under django's unstubbed safestring, so SafeString is the correct str-compatible annotation that cleanly absorbs the Any without a # type: ignore). Rendering is byte-identical — annotations are runtime no-ops, verified by diffing the rendered HTML of representative UI components (spinner/alert/modal) against the pre-change versions (identical output). mypy python/djust stays GREEN (822 files) with all 185 strict and the rest lenient; gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict module (ui/spinner, descriptors/modal) turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed. Remaining components/ for the continuation batch: the Rust-bridge handler registry (rust_handlers — the ~360-error mark_safe/kw.get()-object iceberg, a separate decision), the per-widget mixins/data_table, the big templatetag modules (templatetags/djust_components/_advanced/_forms/_charts), the gallery/live_views/views/examples LiveViews, the ~24 components/components/* widgets with untyped private-helper params (_render_node/_squarify/_compute_diff/etc.), and the union-typed ui/*_simple widgets + ui/navbar_simple/modal/dropdown (over-narrow dict inference + the declared-but-unbuilt RustNavBar/Rust* fallback imports). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the components/ machinery — 15 core modules (ADR-023 M4b, part 1). The fifth ratchet step (after M1's foundation, M2's public-API quartet, M3's dispatch core, M4a's loose top-level batch) flips the core component-system machinery — NOT the UI catalog — from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any): components.__init__, components.apps, components.registry (the LiveComponent name registry), components.assigns, components.dependencies (the DependencyManager CSS/JS asset registry), components.function_component (the @component decorator + {% call %}/{% slot %} dispatch handlers), components.helpers, components.presets (the tag-preset registry), components.icons (the Heroicons SVG renderer + render_icon), components.suspense (the {% dj_suspense %} fallback renderer), components.server_event_toast (ServerEventToastMixin), components.utils (shared format_cell/interpolate_color/interpolate_color_gradient + CURRENCY_SYMBOLS), components.mixins.base (the per-component interactive mixin base — ComponentMixin + the TypedState dict subclass), components.templatetags._registry (the shared template.Library + the security-sensitive safe_url scheme-validator + _resolve/_parse_kv_args), and components.templatetags._dev_tools (the Terminal/MarkdownEditor/JsonViewer/LogViewer/FileTree dev-tool template tags). Annotated with real types (params + returns — not Any cosmetics); the only narrow coded # type: ignore[attr-defined] are at genuine dynamic edges (the @component decorator stamping _djust_* metadata onto a plain Callable; the per-invocation _slots/_children attached to a LiveComponent instance for template render). The mark_safe-returns-Any boundary is handled with typed-local narrowing (a small _safe(html: str) -> str wrapper in _dev_tools, str-typed locals elsewhere) — no Any leak. mypy python/djust stays GREEN (822 files) with all 15 strict and the rest lenient; gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict module (utils.interpolate_color) turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed. Remaining components/ for the continuation batch: the Rust-bridge handler registry (rust_handlers, ~360 errors once -> str is added — the mark_safe/kw.get()-object iceberg), the per-widget mixins.data_table, and the big templatetag modules (djust_components/_advanced/_forms/_charts), plus the UI catalog (ui/, data/, forms/, gallery/, charts UI). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on 15 loose top-level modules (ADR-023 M4a). The fourth ratchet step (after M1's foundation, M2's public-API quartet, M3's dispatch core) flips a batch of independent, low-cross-risk top-level modules from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any): serialization (the wire-boundary JSON/normalizer — DjangoJSONEncoder._serialize_model_safely + the finding-#19 denylist/allowlist/opt-out field gate + normalize_django_value), config, __init__, routing (the live_session URLconf walk + auth-filtered route-map emit), formsets, simple_live_view, testing (the public LiveViewTestClient + SnapshotTestMixin + LiveViewSmokeTest fuzz/smoke harness), react, rust_components, frameworks (the CSS framework adapters), js (the JS command-chain builder), wizard (WizardMixin), performance, profiler, and presence (PresenceMixin + LiveCursorMixin). Annotated with real types (params + returns — not Any cosmetics); the only # type: ignore[misc] are at genuine mixin super()-delegation edges (wizard mount/get_context_data, which the LiveView MRO supplies at runtime). mypy python/djust stays GREEN (822 files) with all 15 strict and the rest lenient; gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict module turns the gate RED, reverting restores GREEN. Full suite 8604 passed / 0 failed. The ratchet continues one batch per PR (the remaining long tail: mixins/components/theming/CLI). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the dispatch/runtime core — runtime, websocket, sse, streaming, websocket_utils (ADR-023 M3). The five modules that form the WebSocket/SSE/ViewRuntime dispatch spine (every mount + event flows through them) are now mypy strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any), the third ratchet step after M1's foundation and M2's public-API quartet. Safe to type now that the ADR-022 ViewRuntime convergence has settled (no spine code about to move). Annotated with real types (params + returns — not Any cosmetics): runtime.py (42 strict errors — ViewRuntime dispatch helpers, _build_request/_check_auth/_extract_*, the actor-mount path, _tenant_context), websocket.py (73 — LiveViewConsumer lifecycle connect/disconnect/receive, the handle_* verb handlers, the Channels event handlers server_push/db_notify/presence_event/etc., _run_async_work/_dispatch_single_event, _mount_one's 5-tuple return, the module helpers _snapshot_assigns/_compute_changed_keys/render_embedded_child_html), sse.py (17 — the DjustSSE*View get/post HTTP handlers, the owner-binding helpers, the SSE event-stream async generator), streaming.py (6 — StreamingMixin, with TYPE_CHECKING host-class attribute declarations), and websocket_utils.py (7 — the shared event-security pipeline). Only two narrow coded # type: ignore[arg-type] for genuine frame-dynamic edges (the dormant actor-event-name forward; the no-binary receive() text frame). mypy python/djust stays GREEN (822 files); gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict module turns the gate RED, reverting restores GREEN. Full suite 8604 passed / 0 failed. The ratchet continues one module per PR (M4: mixins/components/theming/long tail). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the public-API quartet — live_view, component, decorators, forms (ADR-023 M2). The four developer-facing modules that py.typed exposes to downstream consumers are now mypy strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any), the second ratchet step after M1's foundation. Annotated: live_view.py (as_view/__init__/live_view decorator + private-state helpers) and its PEP 561 stub live_view.pyi; components/base.py (the Component + LiveComponent public bases — descriptor protocol, render waterfall, event-handler factory); decorators.py (@event_handler, @action, @server_function, @reactive, @state, @computed, @optimistic, @background + their nested wrappers/descriptors); and forms.py (FormMixin + LiveViewForm). mypy python/djust stays GREEN (822 files); the strict flip is gate-off-verified (#1468) — injecting a wrong-typed return into one of the four turns the gate RED, the same error in a lenient module stays GREEN. The ratchet continues one module per PR (M3: the dispatch/runtime core). See docs/adr/023-incremental-type-enforcement.md.

  • Enforced incremental type-checking — a mypy merge gate + strict islands + the _rust.pyi boundary (ADR-023). djust ships py.typed (PEP 561 — downstream consumers type-check against djust's hints), and pyproject.toml declared a strict [tool.mypy] config — but mypy was invoked nowhere (CI / Makefile / pre-commit), so the strict config was dead and mypy python/djust reported 8,421 errors (≈6,814 missing annotations + ~750 missing-stub imports + ~700 real type errors). This PR restructures [tool.mypy] for incremental adoption: a lenient global default (ignore_missing_imports = true + ignore_errors = true) that parks the legacy baseline so the gate is GREEN, plus per-module strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any) that genuinely enforce every error class on a 22-module starter set led by the security boundary (djust.security.*) and the PyO3 wire boundary (djust._rust, typed by _rust.pyi), plus rate_limit, validation, permissions, markdown, schema, signals, async_result, test_isolation, and the well-annotated _-prefixed leaf modules (_client_ip, _log_utils, _html, _view_resolution, _deprecation, _context_provider). The gate is enforced: a non-continue-on-error mypy step in the python-tests CI job (a new MERGE GATE — #1236 governance — wired into the test-summary AND-condition; ships gating because it is green by construction, per #1534), a make typecheck target (in make check), and a scoped pre-commit hook on python/djust/**.py{,i} changes. The _rust.pyi stub's top-level names are pinned to exactly match the compiled module's runtime exports and a strict island (markdown) imports through it, so the wire/serialization boundary is type-checked, not merely declared. Empirical canary (#1459): an injected missing-annotation / wrong-typed-return in a strict island makes the gate RED, while the same error in a lenient module stays GREEN — the gate is real, not cosmetic. The ratchet is one-module-per-PR, prioritising the developer-facing public API (live_view/component/decorators/forms) since py.typed exposes it. See docs/adr/023-incremental-type-enforcement.md.

  • Mount-spine parity nets + 6 real-WebsocketCommunicator flip gap-tests for the WS mount convergence (#1911, ADR-022 Iter 3 Phase 3.0). The regression net the eventual mount flip (Phase 3.3b) will ride. python/djust/tests/test_ws_mount_flip_parity_1911.py characterizes the six mount behaviors the flip must preserve, driving each against the CURRENT bespoke handle_mount over a real channels WebsocketCommunicator (each passes now + must stay green through the flip = the parity proof, #1466/#1780/#1468): actor MOUNT (a use_actors view renders an actor-backed mount frame, NOT the SSE refusal — Finding D), sticky_hold-before-mount-frame ORDERING via live_redirect_mount (Finding B), Channels group_add server-push reachability (a broadcast to the mounted view's group reaches the session), periodic tick started at mount (a source="tick" frame arrives with no client event), optimistic_rules + upload_configs on the mount frame, and live_redirect re-mount idempotency (mount A → live_redirect to B → B actually mounts, not a no-op — THE Finding-A net: the bespoke path nulls self.view_instance before re-mounting, and a naive flip that forgets to also reset runtime.view_instance would silently no-op the re-mount since dispatch_mount early-returns when view_instance is not None). Each asserts intermediate state + has a gate-off/contrast sibling. python/djust/tests/test_transport_behavioral_parity.py grows the mount-spine nets (mount-stash + dirty-baseline pins, mount-async/push-drain parity, mount-frame wire-version parity per Finding C's no-arm baseline, a two-queues-not-_flush_all_pending source pin) and extends _WS_ONLY_MARKERS with the WS-only mount behaviors (create_session_actor, state_snapshot_signed, _find_sticky_slot_ids, tick_interval, register_view) so a future "moved to runtime" of one trips RED. No WS routing change: RUNTIME_OWNED_VERBS ({"url_change", "event"}) and handle_mount/handle_mount_batch are UNTOUCHED.

Changed

  • Perf (cold-start): warm the Django→Rust custom-filter bridge at startup instead of on the first mount. Request-path profiling showed the first mount after server boot paid a one-time ~20 ms cost: rust_bridge._ensure_custom_filters_bridged() lazily triggers Django to import every templatetag library (via engine.template_libraries) on first access. It's memoized after, so steady-state is unaffected — but the first request ate the latency. DjustConfig.ready() now eagerly runs the bridge (new _warm_filter_bridge() helper) so that one-time cost lands at startup, not in the first user's request. Idempotent + non-fatal; skipped under pytest (mirrors the hot-reload gate); opt out via LIVEVIEW_CONFIG['filter_bridge_warm'] = False. New cases in test_auto_hot_reload.py (TestFilterBridgeWarm-class behaviors, gate-off via the opt-out test). No steady-state behavior change.

  • CI: CodeQL config excludes py/ineffectual-statement (false-positive noise from the ADR-023 TYPE_CHECKING stub idiom). The strict-mypy ratchet added if TYPE_CHECKING: forward-declaration blocks across the mixins (cooperating-attribute/method stubs with ... bodies so each strict-island mixin resolves names supplied by sibling classes at MRO time — zero runtime effect). CodeQL's py/ineffectual-statement flags every ... Ellipsis body; all 50 hits were this idiom (the genuine useless-expression class is covered by ruff). Added the rule to .github/codeql/codeql-config.yml's query-filters so it doesn't recur as the type-checking blocks grow. Also converted a cast("Any", …) string forward-ref to a direct cast(Any, …) in components/rust_handlers.py so CodeQL sees the Any import as used (#2553).

  • CI: the shared Playwright harness now waits for the demo server's actual canary route to be ready before the browser run — removes the cold-cache page.goto flake on the BLOCKING browser-smoke gate (#1943). The blocking browser-smoke gate (#1869) intermittently red-barred unrelated PRs with Page.goto: Timeout 30000ms exceeded navigating to /demos/browser-smoke/: on a cold-cache run that compiles the djust_components Rust crate from scratch, the demo uvicorn server wasn't ready within the fixed 30s page.goto deadline (it cleared on a warm-cache re-run — not a real break). The .github/actions/djust-playwright-server readiness step (shared by every playwright job) is fixed at the source: (1) its poll bound is bumped 30s → 120s (60 attempts × 2s); (2) it now polls the ACTUAL canary target route (/demos/browser-smoke/) in addition to /, using curl -fsS so a half-initialized app's 5xx does NOT count as ready — this warms the route (first-hit lazy import/compilation) DURING the bounded loop so page.goto lands on an already-warm route instead of racing its own deadline. tests/playwright/test_browser_smoke.py's page.goto also gets an explicit 60s timeout (belt-and-suspenders, up from the 30s default). The gate's SIGNAL is preserved: a genuinely-down server still fails LOUD (exit 1 + server.log dump) once the 120s bound is hit, so a real runtime break of either #1849/#1848 class still red-bars the PR.

  • CI: the Playwright browser-smoke canary is now a HARD merge gate, and the #1848 inline-script check is now a hard assertion (#1869, Action Tracker #314). The #1849/#1848 runtime-break canary (tests/playwright/test_browser_smoke.py, which drives /demos/browser-smoke/ and guards the 1.0.7 runtime-break class — a LiveView refused at WS mount, and an inline <script> inside the dj-root whose delegated listener never registers under the #1610 mount morph) was carved out of the already-non-blocking playwright-tests leg into its OWN dedicated browser-smoke CI job (no continue-on-error) and wired into the test-summary aggregate gate's AND-condition, so a re-introduced runtime break of either class now red-bars the PR (mirrors the demo-checks blocking-job pattern, #1708/#1713). Promoted per #1534 only after the canary shipped green on the runner across multiple PRs in the non-blocking leg. The rest of the playwright suite (loading_attribute / cache_decorator / draft_mode / nav_hooks) stays in the non-blocking playwright-tests leg — the full suite can be flaky; only this stable two-class canary gates. The inline-script (#1848) branch of the canary, previously a tolerated known-xfail (warn-not-fail when the inline <script> never ran), is flipped to a HARD assertion now that PR #1871 fixed #1848 (re-execute classic <script> on the #1610 mount morph via window.djust._runInsertedScripts); a future regression of that fix now hard-fails the now-gating canary.

  • WS mounts now route through ViewRuntime.dispatch_mount — THE MOUNT FLIP, the #1646 mount convergence COMPLETE (#1919, ADR-022 Iter 3 Phase 3.3b). "mount" joins "url_change" + "event" in RUNTIME_OWNED_VERBS, so receive() routes every WS mount frame through the single dispatch_messagedispatch_mount chokepoint, and the ~870-line bespoke handle_mount body is DELETED — reduced to a THIN SHIM over dispatch_mount (mirroring the event flip #1907 and handle_url_change). Phases 3.0-3.3a had already grown dispatch_mount into a functional superset (F22 view resolver, run_pre_mount_auth pre-mount auth+tenant via _check_auth, on_mount hooks, session + signed-snapshot state restore, post-mount object-permission, handle_params, actor mount, no-arm mount wire version, the sticky_hold pre-mount frame, the auth verdict→close finalize, the 2-queue mount-time drain) via WSConsumerTransport hooks. This PR is the atomic flip with the three load-bearing findings wired: (A) idempotency — the shim, the _dispatch_runtime_owned mount arm, disconnect, and the live_redirect teardown all null runtime.view_instance BEFORE dispatch, so a reconnect / live_redirect re-mount is never silently no-op'd by dispatch_mount's if view_instance is not None early-return (the #560-class landmine); (B) ownership inverts — mount CREATES the view (runtime→consumer), so the shim reads back self.view_instance = runtime.view_instance, and the WS post-mount consumer setup the bespoke body did but the runtime did NOT (server-push / presence / db_notify group_add, the periodic tick_interval task, the use_actors flag, the real-scope _websocket_path/_websocket_query_string stamps, the _sticky_auto_reattached reset) is folded into the now-LIVE WS on_view_mounted transport hook — made async to await group_add — Finding B residual; (C) mount wire version via the next_mount_version hook (the no-arm consumer counter). The object-perm denial now closes the socket via finalize_mount_auth (Finding E — the bespoke unconditional close(4403) had no runtime equivalent). A mount_batch bug the flip surfaced is also fixed: ViewRuntime._instantiate_view fire-and-forgot its error frame via asyncio.ensure_future, leaking a FAILED view's error into the NEXT survivor's collector (flipping a survivor to failed[]); it now stashes the frame and dispatch_mount await-sends it inside the correct _mount_one window. handle_mount_batch / _mount_one stay WS-only (the collector contract is unchanged; finalize_mount_auth still gates the redirect-verdict close on not _mounting_in_batch per #291/#1780). Boundary pins updated to the post-flip reality: the RUNTIME_OWNED_VERBS contract, the Concern-4 mount-orchestration count-canary (run_pre_mount_auth / object-perm / validated_host_from_scope converged onto runtime.py), _WS_ONLY_MARKERS (group_add / channel_layer / tick_interval moved to the runtime hook), and the handle_mount source-grep pins (snapshot sign/unsign, skip-html, _ensure_tenant-before-restore, has_ids, mount-url validation, next-version) moved to dispatch_mount; the fake consumers in test_sw_advanced.py / test_sw_advanced_flow.py gained a permissive _rate_limiter so they drive the runtime path. Gate-off-verified (#1468): neutering the Finding-A null makes the live_redirect re-mount net (test_ws_mount_flip_parity_1911.py::TestLiveRedirectRemountIdempotency) RED; neutering the on_view_mounted fold makes the group_add-reachability + tick-at-mount nets RED. Full CI-way suite (tests/ python/tests/ python/djust/tests/ -n auto): 8577 passed, 0 failed.

  • The 5 transport mount-hooks (#1916) are now WIRED into ViewRuntime.dispatch_mount — it is a functional SUPERSET of the WS handle_mount, and the hooks go LIVE for the SSE/runtime mount path (#1917, ADR-022 Iter 3 Phase 3.3a). The last build-up before the Phase 3.3b atomic flip. Routing stays bespoke — RUNTIME_OWNED_VERBS is UNCHANGED ({"url_change", "event"}), handle_mount / handle_mount_batch are UNTOUCHED (websocket.py has no diff) — but the dormant hooks are now called by dispatch_mount at their WS-faithful positions (read off handle_mount): (1) on_view_instantiated(view) right after instantiation (WS stamps _ws_consumer / _push_events_flush_callback / observability register_view / validated host; SSE no-op) (Finding B). (2) uses_actors_for_mount / dispatch_actor_mount (Finding D) — the hard actor REFUSAL is replaced: a WS use_actors view now RENDERS through the actor system at the render step (verbatim handle_mount ordering — after auth + mount() + handle_params, html sent without strip/extract, websocket.py:2691-2706); SSE keeps refusing (uses_actors_for_mount → False, so the structured use_actors is not supported over SSE envelope is now reached only when the transport does NOT support actor mounts). (3) next_mount_version(html, rust_version) (Finding C) — the mount-frame version routes through the NO-ARM hook (WS consumer._next_version() — establishes the baseline, does NOT arm request_html recovery so _recovery_html stays None; SSE returns the raw Rust render_with_diff() version, IMPLEMENTED here — the 3.2 SSE placeholder raised). The signature is widened to (html, rust_version=1) mirroring next_client_version so the runtime hands every transport the same inputs; the default keeps the 3.2 single-arg callers working. Crucially mount does NOT route through the ARMING next_client_version the event path uses. (4) on_mount_render_ready(view, html) (Finding B residual) runs after render, before the mount frame (WS sticky preservation + the sticky_hold frame emitted BEFORE the mount frame; SSE returns html unchanged). (5) finalize_mount_auth(view, verdict) (Finding E) on the three auth-block verdicts (_check_auth permission_denied + redirect; dispatch_mount run_on_mount_hooks redirect) — the runtime already sent the verdict frame + cleared view_instance, so the hook adds ONLY the transport-level close(4403) (WS unconditional for permission-denial, gated on not _mounting_in_batch for the redirect verdicts per #291/#1780; SSE no-op); it does NOT re-send the frame. Every hook is getattr-guarded so duck-typed test fakes (and the default-bearing Protocol) keep working. dispatch_mount is now a clean superset (Findings A/B prep) — the idempotency guard + view_instance ownership are untouched; the residual delta for the 3.3b flip is the routing flip + the A/B shim (the runtime.view_instance reset + read-back) only. New cases in TestRuntimeBasicMountParity, TestRuntimeActorMountParity, TestRuntimeNoArmVersionWiring, TestRuntimeAuthBlockFinalize, TestRuntimeStateRestoreParity (python/djust/tests/test_runtime_mount_parity_1917.py) — THE key 3.3a gate: drives dispatch_mount over a REAL WSConsumerTransport (direct-call shim, NOT via RUNTIME_OWNED_VERBS) and proves WS-equivalent mount for basic mount, ACTOR mount (renders not refuses), no-arm version, auth-block #291-not-in-batch, and state restore (Phase 3.1), plus two routing-untouched pins; gate-off-verified (#1468) — the actor branch off → the view is refused again, the next_mount_version wiring off → the wrong version is stamped. The Phase-3.2 DORMANT pins in python/djust/tests/test_transport_mount_hooks_1915.py are INVERTED to load-bearing WIRED pins (each hook is now referenced in dispatch_mount / the auth helper; SSE next_mount_version returns rust_version).

  • The 5 transport mount-hooks the WS-mount flip needs are now DEFINED — DORMANT scaffolding, not yet wired into dispatch_mount (#1915, ADR-022 Iter 3 Phase 3.2). Internal scaffolding PR — zero live behavior change. Mirrors how Phase 2.3a defined the event hooks (event_context / on_event_recorded / dispatch_actor_event) DORMANT before the event flip wired + routed them. The 5 hooks land on the Transport protocol (behavior-preserving no-op / refuse defaults), WSConsumerTransport (the real WS impl, each encapsulating the verbatim bespoke handle_mount logic for its cited site), and SSESessionTransport (no-op / raw / refuse), addressing ADR-022 Iter 3 Findings B/C/D/E: (1) on_view_instantiated(view) — WS stamps view._ws_consumer + wires _push_events_flush_callback (websocket.py:2128/2134-2135), registers the view in the observability registry (2161-2167), and stashes the validated _websocket_host/_websocket_secure (2243-2270) (Finding B); SSE: no-op. (2) uses_actors_for_mount(view) + dispatch_actor_mount(view, data) — WS: use_actors and create_session_actor is not None (websocket.py:2213) → create_session_actor + actor_handle.mount(){html, version} (2213-2217/2665-2706), verbatim (Finding D); SSE: False / raise (the dispatch_mount refusal stays). (3) next_mount_version(html) — WS returns consumer._next_version(), the NO-ARM counter handle_mount uses (websocket.py:2746); crucially it does NOT call _next_version_armed / _arm_recovery (a mount ESTABLISHES the client VDOM baseline and has no prior frame to recover to — distinct from next_client_version, which arms for render-SEND frames), so _recovery_html stays None after a mount (Finding C / #1817); SSE: raw Rust version (placeholder, raises until 3.3a wires it). (4) on_mount_render_ready(view, html) — WS: sticky preservation (_find_sticky_slot_ids survivor scan + _register_child re-registration) + the sticky_hold frame emitted BEFORE the mount frame (websocket.py:2080-2082/2836-2903), returning html unchanged; SSE: returns html unchanged (Finding B residual). (5) finalize_mount_auth(view, verdict) — WS: the transport-level socket close(4403) the bespoke auth-finalization performs (websocket.py:2337-2401), GATED on not consumer._mounting_in_batch for the redirect verdicts so a batched login-required view does NOT drop the shared socket's sibling mounts (#291/#1780), unconditional for a permission-denial; SSE: no socket to drop → no-op (the runtime-sent error/navigate frame is the SSE finalization). DORMANT: dispatch_mount does NOT call any of these yet (Phase 3.3a wires them in) and the WS bespoke handle_mount / handle_mount_batch keep doing all of this inline (untouched until the Phase 3.3b flip); RUNTIME_OWNED_VERBS / WS routing are UNTOUCHED; websocket.py has no production diff. New cases in python/djust/tests/test_transport_mount_hooks_1915.py (Test... MockTransport unit tests per hook + real-WebsocketCommunicator tests exercising the WS impls in isolation against a genuinely-mounted consumer — uses_actors_for_mount True for a use_actors view, next_mount_version returns the consumer counter WITHOUT arming recovery, finalize_mount_auth does NOT close when _mounting_in_batch=True) + DORMANT pins (dispatch_mount doesn't reference the hooks, still stamps the raw Rust version + still refuses actor mounts; handle_mount still does the work inline). All gate-off-verified (#1468): arming recovery in next_mount_version reds the 3 no-arm tests, removing the not _mounting_in_batch gate reds the in-batch tests, no-op'ing on_view_instantiated reds the stamp test. The anti-drift _WS_ONLY_MARKERS pin (test_transport_behavioral_parity.py) drops create_session_actor / _find_sticky_slot_ids / register_view (no longer WS-only — the dormant WS hooks now reference them in runtime.py), mirroring the Phase-3.1 state_snapshot_signed move.

  • ViewRuntime.dispatch_mount grew the transport-agnostic mount STATE-RESTORE + on_mount hooks WebSocket handle_mount has, going LIVE for SSE mount (#1913, ADR-022 Iter 3 Phase 3.1). Second PR of the WS mount convergence (after Phase 3.0's cheap grows, #1911). Three ports, each gated on enable_state_snapshot (#1552) so default views are unaffected: (1) run_on_mount_hooks (websocket.py:2383-2401) runs the registered on_mount hooks after the pre-mount auth sequence + before mount(); a hook that returns a redirect URL emits a navigate frame, clears the unmounted view, and aborts — transport-agnostically (no socket close(); that belongs to the Phase 3.2/3.3a finalize_mount_auth hook, matching the runtime's existing auth-redirect handling in _check_auth). (2) Session-saved-state restore (websocket.py:2424-2474) reattaches the public + private state + per-process side-effect registrations (_restore_upload_configs / _restore_presence / _restore_listen_channels, hasattr-guarded) + component state the per-event session-save (#1466) wrote, on a plain reconnect-mount — in lieu of mount(). (3) The has_prerenderedskip_html_for_resume resume optimization Phase 3.0 wired (but left dormant) now ACTIVATES: a restore (session or signed-snapshot) sets the new _mounted_from_restore framework flag, so a resuming client that already holds the DOM skips the redundant mount-HTML swap (the version still flows so patches stay in sync). _mounted_from_restore is initialized in LiveView.__init__ BEFORE the _framework_attrs snapshot (#1393) so it is reset on reconnect and never persisted. Blast radius: SSE mount (which uses dispatch_mount) + the runtime; websocket.py has no diff, RUNTIME_OWNED_VERBS / handle_mount / handle_mount_batch are unchanged. The anti-drift _WS_ONLY_MARKERS pin drops state_snapshot_signed (no longer WS-only — now on the runtime too) and the live_view.py setattr-whitelist line numbers shift +11. New cases in python/djust/tests/test_runtime_mount_state_restore_1913.py (TestRuntimeSessionRestore, TestRuntimeOnMountHooks): an opt-in view's session-saved state restores on a runtime reconnect-mount while a default view ignores it (#1552 gate-off, RED when the gate is dropped); an on_mount redirect emits a navigate frame + aborts (RED when the redirect handling is gated off).

  • ViewRuntime.dispatch_mount grew the transport-agnostic mount behaviors WebSocket handle_mount has, going LIVE for SSE mount (#1911, ADR-022 Iter 3 Phase 3.0). First PR of the WS mount convergence — grows the runtime mount path toward a functional superset of handle_mount over zero-WS-routing-risk PRs (the eventual flip is Phase 3.3b). Five grows, each ported from its WS site, gate-off-verified (#1468): (1) the _djust_mount_request / _djust_mount_kwargs stash (#1895, websocket.py:2596, placed after mount() + object-perm, before handle_params) — the runtime's OWN per-event session-save fallback (runtime.py:2030/2109) already READS this attr to discover the save session + liveview_{path} namespace, so the stash makes that fallback live on the converged path instead of silently degrading to the scope session; (2) _snapshot_user_private_attrs + _capture_dirty_baseline post-mount (websocket.py:2598-2603); (3) has_prerenderedskip_html_for_resume machinery (websocket.py:2804-2816), dormant until Phase 3.1 wires session-restore (the _mounted_from_restore flag defaults False, so HTML is always sent today); (4) optimistic_rules (DEP-002) + upload_configs on the mount frame (websocket.py:2823-2834, via a new runtime _extract_optimistic_rules mirror); (5) the mount-time _flush_push_events() + _dispatch_async_work(None) drain (websocket.py:2916, #1280/#1283) — ONLY those two queues, NOT the 8-queue _flush_all_pending the turn-end event path uses (mount establishes a baseline, it does not run a full event turn-end flush), with the #1391 source-grep pin MOVED to the runtime location in test_handle_mount_drains_queues.py. Blast radius: SSE mount (which uses dispatch_mount) + the runtime; websocket.py has no diff and RUNTIME_OWNED_VERBS is unchanged. Every grow has a gate-off witness in test_transport_behavioral_parity.py (7/7 verified RED). New cases in TestMountStashAndBaselines, TestMountAsyncAndPushDrain, TestMountFrameOptimisticAndUpload, TestMountFrameWireVersion.

  • THE FLIP: every WebSocket event now routes through ViewRuntime.dispatch_event — the bespoke _handle_event_inner is deleted (#1907, ADR-022 Iter 2 Phase 2.3b). The atomic moment of the event-path convergence (the #1646 cure: one event path, not two). "event" is added to RUNTIME_OWNED_VERBS (now {"url_change", "event"}), so receive() routes every WS event through the single ViewRuntime.dispatch_message chokepoint; handle_event becomes a thin shim over runtime.dispatch_event (mirroring handle_url_change); and the ~1170-line bespoke _handle_event_inner — the WS-only twin the runtime grew to a functional superset in Phase 2.3a (#1900/#1902/#1904/#1906) — is removed. The residual observability the bespoke handler owned is folded onto two new Transport hooks (SSE no-op): on_render_emitted carries the production-visible DJE-053 warning (#1079 — it MUST survive, and does) plus the _emit_full_html_update signal on the no-patch render branch, and on_handler_timing carries the record_handler_timing percentile telemetry; cache_request_id was already threaded through the runtime render path. The flip surfaced + fixed three parallel-path-drift regressions now that the runtime event path IS the WS event path: (1) ViewRuntime._flush_navigation is now await-ed (was fire-and-forget) and (2) the skip-render branch now calls _flush_all_pending, so a live_redirect() / navigation command queued by a state-unchanging handler still emits its navigation frame within the event turn (WS parity); and (3) the runtime's _dispatch_event_render now records a time-travel snapshot with error="permission_denied" / "validation_failed" on the security-rejected + validation-rejected early-return paths (record_event_start moved BEFORE the security check) — the bespoke _handle_event_inner recorded these for the debug panel, and the first flip pass dropped them for non-actor views (caught by tests/integration/test_time_travel_flow.py::test_permission_denied_view_handler_records_with_error). Boundary pins updated (RUNTIME_OWNED_VERBS contract, the event routing pin, the _handle_event_inner-deleted assertion) and the WS-source pins (1465 save-block, 1785 recovery-arming, 1788 wire-version count, 1802 sticky-child) redirected to the runtime where the behavior now lives. New TestResidualFoldObservability (DJE-053 + record_handler_timing survival, with reason/version gate-off siblings) and a WebsocketCommunicator regression for start_async / @background streaming its source="async" result over the runtime async path. Gate-off (#1468): removing "event" from RUNTIME_OWNED_VERBS makes all 11 test_ws_event_flip_parity_1896 behaviors fail with Unknown message type: event (the bespoke elif is gone) — proving the set membership is the only switch. The DEBUG-only debug-panel payload + cosmetic consumer attrs are deferred to #1908 (inert in production). Full suite green the way CI runs it (tests/ + python/tests/ = 4732 passed; python/djust/tests/ = 3750 passed; 0 failed, 21 skipped); the entire WS event regression net (reconnect-state #1465, sticky-child #1802/#1813, reauth #1777, send-version #1788, recovery-staleness #1817, url-change wire-version #1858, transport-hardening F21/F17, ratelimit-per-caller F27/F28) stays green.

  • ViewRuntime async-result frames now carry source="async", reconciling them with the WebSocket _run_async_work frames; and the dead use_binary framing path is confirmed + pinned (#1905, ADR-022 Iter 2 Phase 2.3a). Two folds finishing the 2.3a parity before the 2.3b WS-event flip. (1) async source="async" reconcileViewRuntime._render_async_result (the start_async / @background completion render shared by the success + error paths) emitted patch / html_update frames with NO source tag, while the WS _run_async_work tags all four of its frames source="async" (websocket.py:1166/1186/1223/1238). The client uses source to distinguish an out-of-band background-completion update from the in-turn source="event" response, so the runtime frames were the lone untagged twin — a #1646 parallel-path drift INSIDE the convergence target. Both runtime async-result branches now stamp source="async". LIVE for SSE + url_change async work (both use the runtime async dispatcher today); WS picks it up post-flip (Phase 2.3b). (2) binary-framing confirmconsumer.use_binary is dead: initialized to False at websocket.py:580 ('MessagePack support TODO') and never set True anywhere in the package; the only honoring site is _send_update's binary branch (websocket.py:1391), which WSConsumerTransport.send does NOT traverse (it calls consumer.send_json, always JSON). DESCOPED (no new binary path invented) + PINNED so a future enable is a deliberate, tested change: a guard test asserts WSConsumerTransport.send emits JSON via send_json (matching live WS), plus a source-grep pin that no production module assigns use_binary = True. No change to RUNTIME_OWNED_VERBS / WS routing; WS _handle_event_inner's async/binary paths stay on the bespoke handler until 2.3b; websocket.py has no diff. New cases in TestAsyncSourceReconcile / TestBinaryFramingConfirm (python/djust/tests/test_runtime_reauth_async_1905.py): real-SSE end-to-end (a start_async completion frame carries source="async") + unit (both branches tagged) + the JSON-emit + source-grep pins, with a gate-off witness (#1468) — removing the source="async" tag makes the SSE end-to-end + unit assertions RED. test_async_integration + test_sse_runtime_convergence_1887 stay green.

  • ViewRuntime gained the transport-agnostic {% dj_activity %} deferral WebSocket has — a defer-when-hidden gate + a lock-free deferred re-dispatcher — and it goes LIVE for SSE events (a parity improvement) (#1903, ADR-022 Iter 2 Phase 2.3a). The runtime event path lacked dj_activity deferral entirely: an event targeting a HIDDEN (non-eager) {% dj_activity %} region should be queued + acked with a no-op (no render) and replayed when the panel next shows, exactly as the bespoke WS _handle_event_inner does (websocket.py:3254-3273 gate + 4290-4294 flush). Two parts: (1) GateViewRuntime._dispatch_event_render (after embedded-child routing, before security validation) replicates the WS gate VERBATIM, reusing the SAME transport-agnostic ActivityMixin view methods (is_activity_visible / _is_activity_eager / _queue_deferred_activity_event); a hidden-region event is queued and answered with the runtime's self-describing noop (type/source/event_name/ref) and no render. (2) Flush + lock-free re-dispatcher (option (a)) — after a render that may flip visibility (BOTH the skip-render and render arms, mirroring the WS post-turn flush), ViewRuntime._flush_deferred_activity_events() hands the runtime ITSELF to the consumer-blind ActivityMixin._flush_deferred_activity_events as the _dispatch_single_event provider, so mixins/activity.py is UNCHANGED (the flush already accepts any object exposing that method). The new ViewRuntime._dispatch_single_event(target_view, event_name, params, event_ref=None) re-runs validate → handler → render for one queued event WITHOUT acquiring a lock and WITHOUT re-entering event_context — it already runs inside the borrowed context (which on WS holds the consumer _render_lock; re-acquiring the non-reentrant asyncio.Lock would deadlock, the websocket.py:1467 contract). A denied queued event is re-validated and dropped (WS flush per-event parity). Live behavior: this goes LIVE for SSE events — they route through dispatch_event since Iter 1 (#1887), so SSE events now respect dj_activity deferral (the parity improvement); a no-op for SSE views with no activity region (zero-cost when unused). WS events are UNAFFECTED — the bespoke _handle_event_inner gate/flush stays until Phase 2.3b; RUNTIME_OWNED_VERBS / WS routing are UNTOUCHED; websocket.py has no diff. New suite python/djust/tests/test_runtime_dj_activity_1903.py — direct-runtime (MockTransport) + real-SSE end-to-end, each reproduce-first + gate-off (#1468): hidden-activity event → queued + noop (no render); flip-visible → the queued event drains in the same round-trip (2nd frame); no-activity view → renders normally; the re-dispatcher runs inside the borrowed context with no re-entry (no-deadlock proof, asserted via a re-entry-recording mock context); a denied queued event is re-validated + dropped; plus structural pins (gate lives in _dispatch_event_render; re-dispatcher body is lock-free; the flush passes the runtime as the dispatcher). Gate-off verified: disabling the gate makes the hidden-deferral + flip-drain tests RED; disabling the flush makes the flip-drain tests RED. The existing WS dj_activity behavior (tests/unit/test_activity.py), the #1896 parity net (bespoke path, unchanged), and test_sse_runtime_convergence_1887 stay green.

  • ViewRuntime gained an actor-event transport hook (transport.uses_actors() + transport.dispatch_actor_event()) so a use_actors view's events route through the per-session Rust actor on the runtime path too — DORMANT until the Phase 2.3b WS-event flip (#1901, ADR-022 Iter 2 Phase 2.3a). The load-bearing fold the WS-event flip sits on. ViewRuntime.dispatch_event had NO actor branch, while the use_actors guard lived ONLY in dispatch_mount (which refuses SSE outright). A WS view mounts in actor mode (use_actors=True + a created actor_handle); once Phase 2.3b routes WS events through the runtime, such a view's events would have hit dispatch_event with no actor branch and silently run the handler IN-PROCESS via the normal render path, desyncing the actor's server-side diff baseline. Two new Transport hooks close the gap: (1) uses_actors(view)WSConsumerTransport returns consumer.use_actors and consumer.actor_handle is not None (the exact precondition of the bespoke WS actor block, websocket.py:3282), SSESessionTransport returns False (SSE has no bidirectional actor channel and dispatch_mount refuses use_actors mounts, runtime.py:602); (2) dispatch_actor_event(view, event_name, params, *, event_ref, cache_request_id)WSConsumerTransport runs the bespoke WS actor block (websocket.py:3282-3379) VERBATIM against the consumer (time-travel record/push in a finally, the shared _validate_event_security + validate_handler_params checks, actor_handle.event(), patch/HTML framing stamped with the consumer-owned wire version consumer._next_version() — the actor's internal result['version'] is IGNORED for the wire, #1788 — error handling, and the v0.7.0 deferred-activity flush), SSESessionTransport raises NotImplementedError (never called — uses_actors is False). Wired into _dispatch_event_inner BEFORE event_context (the actor block holds no render lock, matching WS), gated on uses_actors(view) AND the event NOT being routed to a sticky child — the WS not is_embedded_child_target mutual exclusion (websocket.py:3280-3282); per #1467 a component_id event does NOT reassign the target view and the WS actor block has no component handling, so a component_id event on a use_actors view goes through the actor (parity), and only a view_id resolving to a DIFFERENT child excludes it (_event_routes_to_sticky_child peeks at view_id WITHOUT consuming it, so the non-actor sticky-child routing still pops it). Zero live-behavior change: uses_actors is False for both live transports today (WS events still run on the bespoke _handle_event_inner; SSE refuses actor mounts), so no live event turn reaches the hook until 2.3b. WS routing (RUNTIME_OWNED_VERBS) + the WS _handle_event_inner actor block are UNTOUCHED (they stay until 2.3b); websocket.py has no diff. New direct-runtime suite python/djust/tests/test_transport_actor_event_1901.py (12 cases) builds a WSConsumerTransport over a fake consumer with use_actors=True + a fake actor_handle and asserts dispatch_event routes to dispatch_actor_event (the actor's .event() is called + the framed result is sent via _send_update with the consumer-owned wire version, NOT the in-process handler), uses_actors False for SSE + a WS consumer without actor_handle, a view_id-routed event skips the actor while a view_id-equals-top event still routes to it, and the SSE dispatch_actor_event raises; gate-off verified (#1468) — forcing uses_actors to always return False makes the actor-routing cases go RED (the event falls to the in-process render path). The existing #1896 actor-parity test (test_ws_event_flip_parity_1896.py, the bespoke WS path) + the #1899 event_context suite stay green.

  • ViewRuntime now BORROWS the consumer's render-lock + origin-channel + observability scope for each event via a new transport.event_context() hook, and the dead runtime-local _render_lock is deleted (#1899, ADR-022 Iter 2 Phase 2.3a). Foundational fold the dj_activity re-dispatcher + the 2.3b WS-event flip sit on. Two load-bearing flip-scope findings drove this: (1) ViewRuntime._render_lock was DEAD CODE — declared in __init__, never acquired anywhere — and is removed; the runtime CANNOT own the render lock, because render serialization is consumer-owned (LiveViewConsumer._render_lock, websocket.py:619) and SHARED with the WS-only _run_tick / server_push / db_notify render loops, so a runtime-local lock would be a different object and could not serialize against ticks (the #560 version-interleave bug). (2) So a new async-CM transport.event_context(view) on the Transport protocol + both adapters lets the runtime borrow the consumer's EXISTING lock: WSConsumerTransport.event_context on enter mirrors _handle_event_inner verbatim — await consumer._render_lock.acquire() (the existing object, not a new one), _processing_user_event = True, set the #1677 origin-channel contextvar to consumer.channel_name, start a PerformanceTracker + the SQL capture_for_event scope (websocket.py:3393-3400 / 3150-3154 / 3469-3475); on exit (finally) it resets the origin token, clears _processing_user_event, RELEASES the borrowed lock, and stops the SQL capture + tracker (websocket.py:4311-4313). SSESessionTransport.event_context is a no-op async CM (SSE runs single-threaded off the HTTP request — no concurrent tick/push loop to serialize against). The event handler+render body of _dispatch_event_inner is extracted into _dispatch_event_render and run inside async with self.transport.event_context(self.view_instance): (the view-mounted check stays OUTSIDE the context — a non-None view is needed to borrow its lock, matching WS, which acquires only after the view exists; a future actor-event branch will run OUTSIDE the context, matching WS where the actor block holds no lock). Zero WS-routing risk, no behavior change for current consumers: RUNTIME_OWNED_VERBS + _handle_event_inner are UNTOUCHED, and dispatch_url_change / _dispatch_url_change_inner are a SEPARATE path (untouched) — so this affects ONLY SSE events (the no-op context) and WS events (not routed through the runtime until the Phase 2.3b flip); url_change is unaffected. New direct-runtime suite python/djust/tests/test_transport_event_context_1899.py asserts the WS context borrows the consumer's EXISTING lock object (held inside, released after — incl. on exception), _processing_user_event True-inside/False-after, origin token set+reset, tracker current-inside/cleared-after; the SSE context is a no-op; ViewRuntime no longer owns a _render_lock; with a gate-off sibling (#1468 — a non-acquiring context makes the held-inside assertion go RED). The two existing source-grep pins (save-block gate, 5-grows enumeration) follow the body to _dispatch_event_render; four existing runtime transport mocks grow a no-op event_context.

  • The runtime event spine gained the three transport-agnostic per-event PERSISTENCE subsystems WebSocket has — time-travel record, session state-save (#1466), and sticky-child state-save (ADR-018) (#1894, ADR-022 Iter 2 Phase 2.2). Third PR of the 4-phase WS-event convergence split. ViewRuntime now records + persists per-event state the way the bespoke WS _handle_event_inner does, so the Phase 2.3 final flip (routing WS events through the runtime) persists identically: (1) time-travel recordrecord_event_start / record_event_end wrap the handler call in the single-view, component, and sticky-child branches, scoped per #1467 (component records on the PARENT view since LiveComponents have no separate buffer; a sticky-child records on the CHILD), finalized in a finally so a raising/permission-denied handler still appears in the debug panel; (2) session state-save #1466ViewRuntime._persist_state_after_event mirrors the WS save (private attrs first, then public get_context_data(), then components), gated on top-level-view identity AND enable_state_snapshot (#1552 — default views MUST NOT persist, since unconditional saves left async session I/O in flight that a host snapshot captured unrecoverably) and bounded by a 150ms asyncio.wait_for (#1475); (3) sticky-child state-save ADR-018ViewRuntime._persist_sticky_child_after_event persists a view_id-routed child under its stable sticky key on the both-opt-in predicate (sticky_child_should_persist), with the one-shot opt-in-mismatch warning (warn_sticky_child_optin_skip) in the else-branch. New Transport hook on_event_recorded(view, snapshot) replaces the WS _maybe_push_tt_event direct send: WSConsumerTransport delegates to the consumer's existing _maybe_push_tt_event (single-sourcing the DEBUG-gated time_travel_event frame), SSESessionTransport no-ops (no SSE debug panel today). A runtime-side #1466 source-grep pin (test_runtime_save_block_present_and_gated) asserts the SAME gate / key-shape / 150ms-bound strings the WS pin asserts, so drift between the two save gates goes red on whichever lost the string. No behavior change for current WS consumers — the WS save-block source in websocket.py is UNTOUCHED (the #1466/#1552 grep-pins in test_ws_reconnect_state_1465.py:119/313/320 stay green; event stays out of RUNTIME_OWNED_VERBS, the WS flip is Phase 2.3). New direct-runtime suite python/djust/tests/test_runtime_state_save_tt_1894.py (12 cases) drives runtime.dispatch_event against a MockTransport; each subsystem has a reproduce-first + gate-off pair (#1468) — removing the enable_state_snapshot gate makes a default view wrongly persist (RED), neutering the time-travel record drops the snapshot + hook (RED), and disabling the hook dispatch makes the on_event_recorded assertion fail (RED). Existing WS + runtime suites stay green (test_ws_reconnect_state_1465, test_sticky_child_recovery_1813, test_time_travel.py, test_time_travel_flow.py, test_runtime_child_routing_1892).

  • The runtime event spine gained the three transport-agnostic child-routing subsystems WebSocket has — component_id LiveComponent, view_id sticky-child, and embedded-child render (#1892, ADR-022 Iter 2 Phase 2.1). Second PR of the 4-phase WS-event convergence split. ViewRuntime._dispatch_event_inner now routes embedded children before the single-view path, mirroring the bespoke WS _handle_event_inner subsystems the runtime previously lacked entirely: (1) a view_id-targeted event resolves a sticky/embedded child via _get_all_child_views(), validates the handler against the CHILD, renders the child subtree, and emits a scoped embedded_update {view_id, html, event_name} frame — the client-supplied view_id is never echoed into the user-facing error (sanitize_for_log in the structured extra only, verbatim from WS); (2) a component_id-targeted event resolves a child LiveComponent via _components, validates the handler against the COMPONENT (not the parent), notifies the PARENT's waiters with component_id injected (ADR-002), and emits a parent-scoped full-HTML component_event frame — per #1467 it does NOT reassign the target view; (3) the embedded-child template render is single-sourced (the #1646 cure) — the pure render core, including the security-hardened escape + DEBUG-gate error path (CWE-79/CWE-209), is extracted verbatim into module-level websocket.render_embedded_child_html, the WS _render_embedded_child is now a thin delegating shim, and the runtime calls the same helper (one implementation, no parallel copy to drift). No behavior change for current WS consumers_handle_event_inner routing is untouched (WS events still flow through it; event stays out of RUNTIME_OWNED_VERBS, the WS flip is Phase 2.3) — and SSE is a structural no-op for both checks (no components/sticky → falls through to the single-view path). New direct-runtime suite python/djust/tests/test_runtime_child_routing_1892.py drives runtime.dispatch_event against a MockTransport with a real parent LiveView + sticky child + LiveComponent (TestRuntimeStickyChildRouting, TestRuntimeComponentRouting, TestRuntimeEmbeddedRender); each security-critical guard (component-handler validation, view_id log-sanitization, embedded-error escape) has a reproduce-first + gate-off pair (#1468), all three verified to go RED when the guard is removed. The existing WS child-routing suites (test_sticky_child_event_noop_1802, test_sticky_child_recovery_1813, test_waiter_component_propagation, test_time_travel_flow) stay green — WS path unchanged.

  • The runtime event spine grew toward WebSocket parity — ref echo, source/event_name, _force_full_html, _notify_waiters, and the #700 push-only skip (#1889, ADR-022 Iter 2 Phase 2.0). First PR of the 4-phase WS-event convergence split. ViewRuntime._dispatch_event_inner / _render_and_send (the minimal SSE event spine, SSE's only event path post-Iter-1) gained the transport-agnostic shared behaviors the bespoke WS _handle_event_inner has but the runtime lacked: (1) the client ref (#560) is now echoed back on BOTH the noop and every update frame, coerced to int (type-confusion guard); (2) the noop frame carries source="event" + event_name and the update frames carry source="event" for the client's #560 response-sequencing; (3) a handler that sets _force_full_html now defeats the auto-skip and sends a full html_update (patches discarded, flag consumed), mirroring websocket.py:4039-4040; (4) _notify_waiters (ADR-002 Phase 1b) runs after the handler so wait_for_event futures resolve on the SSE path too; (5) the #700 identity push-only auto-skip (the id()-identity variant beyond the assigns-snapshot skip) is ported, so a push-events-only handler emits a noop instead of a wasted re-render. No behavior change for current WS consumerswebsocket.py is untouched (WS events still use _handle_event_inner; event stays out of RUNTIME_OWNED_VERBS, the WS flip is Phase 2.3) — and SSE consumers gain the #560 ref/source fields. Each grow is reproduce-first + gate-off verified (#1468): new behavioral pins (TestEventSpineRefEcho, TestEventSpineForceFullHtml, TestEventSpineNotifyWaiters, TestEventSpineIdentityPushSkip) and a source-enumeration net (TestEventSpineEnumeration) in python/djust/tests/test_transport_behavioral_parity.py so a future drop re-forks RED; a real-SSE-transport end-to-end suite (TestSSEEventSpineParity in python/djust/tests/test_sse_runtime_convergence_1887.py, driving the /message/ endpoint which forwards the full ref-carrying envelope); and an extended RUNTIME_OWNED_VERBS contract pin (TestRuntimeOwnedVerbsContract::test_event_spine_grown_but_event_not_yet_ws_owned in python/djust/tests/test_ws_receive_runtime_dispatch_1852.py) pinning the Phase-2.0 ↔ 2.3 boundary.

  • The SSE transport's mount + event now route through the shared ViewRuntime, retiring the legacy bespoke SSE copies (#1887, ADR-022 Iter 1). The SSE GET-stream mount and the legacy /event/ POST previously had their own hand-written mount/event/render/async helpers (_sse_mount_view, _sse_handle_event, _sse_handle_event_inner, _sse_run_async_work) — a fork of the same dispatch logic the WebSocket and ViewRuntime paths carry, i.e. a live instance of the #1646 parallel-path-drift class. Both now dispatch through session.runtime.dispatch_mount / dispatch_event — the SAME spine the SSE /message/ endpoint and the WS url_change shim already use — and the legacy helpers (plus their orphaned flush/async/cache sub-helpers) are deleted. No behavior change for SSE consumers: mount still renders against the real authenticated request, events still stream patch/html_update frames, object-permission denial still blocks the mount (now via dispatch_mount's Iter-0 check), and start_async/@background work still streams its result (the runtime grew the async dispatcher SSE needs — this also fixes a latent legacy-SSE drop of start_async named-task work, since the legacy path only dispatched the never-set _async_pending format). SSE-specific behavior is preserved via two new SSESessionTransport hooks: build_request() (the runtime mounts against the real HTTP request, not a synthesized userless one) and on_view_mounted() (stamps _sse_session_id / _sse_session / session.view_instance). No websocket.py changes (WS convergence is Iter 2/3). New end-to-end integration suite python/djust/tests/test_sse_runtime_convergence_1887.py (mount / event / object-perm / start_async via the real endpoints, with gate-off witnesses, #1468); existing SSE + mount-chokepoint + has_ids-parity tests migrated to the converged path.

Performance

  • Keyed per-item loop render cache — large-list render_with_diff reorders re-render only changed items, flag-gated default-OFF (#1967). Node::For in the Rust template engine previously re-rendered every loop item from the AST on every render, so a pure reorder of a 50/500-item keyed list rebuilt all N item subtrees from scratch (~9 µs/item) even though their rendered bytes are byte-identical (only positions changed). A new persistent content-hash → rendered-fragment cache (crates/djust_templates/src/loop_cache.rs, a field on RustLiveView that survives across render_with_diff calls) reuses each unchanged item's fragment, turning the loop-RENDER phase from O(n) toward O(changed): a pure reorder is all cache HITS (0 re-renders), a content-change of K items costs K misses, an append costs 1. Correctness is paramount and proven: the cache is restricted to loop bodies whose rendered output is fully determined by the loop item(s), enforced by TWO gates. (1) Position-dependent bodies are non-cacheable — any {% if %} (dj-if marker carries the loop index, #1832), {% cycle %}, nested {% for %}, {{ forloop.* }} reference, or opaque Python/component tag (a content-hash cache there would emit stale positions). (2) Bodies that read ANY outer-context variable are non-cacheable (#1967 review) — the content hash covers only the loop item(s), but a body can also read outer context ({{ prefix }}, {% with label=flag %}, {% firstof flag x.name %}, settings.X); outer context is constant within a render but NOT across renders, and the cache is persistent across renders, so a reorder after an outer-var change would serve stale fragments. A body is therefore cacheable ONLY if every top-level variable it reads is one of the loop's bound name(s) (x.name/x.price resolve under loop var x → allowed; prefix/flag → non-cacheable; tuple-unpacking for k, v allows both k and v); the dep-subset test reuses the engine's existing partial-render dependency extractor (parser::body_root_var_names). Both gates are detected once per For-node and memoized. This narrows the cacheable surface to item-only bodies — the common data-list case ({{ item.field }} only) — while non-cacheable bodies fall back to normal per-item render (correct, no win). The cached fragment is the template-render output BEFORE dj-id assignment (dj-ids are assigned downstream in the html5ever parse phase), so the keyed VDOM diff (#1678/#1682) is unaffected — output is byte-identical with the cache on vs off, verified across initial render / reorder / content-change / append / remove on plain, forloop.counter, dj-if, {% cycle %}, nested, tuple-unpacking, outer-context ({{ prefix }} / {% with %} / {% firstof %}), and dj-key templates. Default OFF (split-foundation #1122 — a hot-path change that must soak); enable via LIVEVIEW_CONFIG['loop_render_cache_enabled'] = True. When off, the For-node path is byte-identical to before. Render-phase reorder bench (crates/djust_templates/benches/loop_render_cache.rs, criterion, item-only body): N=50 ~83 µs → ~50 µs (~1.7×), N=500 ~819 µs → ~515 µs (~1.6×) — the win survives for cacheable bodies. New TestOutputIdentity / TestCacheBehavior / TestLoopRenderCacheDefaults / TestOuterContextNonCacheable classes in python/djust/tests/test_loop_render_cache_1967.py (13 end-to-end via RustLiveView.render_with_diff) + crates/djust_templates/tests/test_loop_render_cache_1967.rs (17 Rust correctness cases incl. three gate-offs (#1468): the position guard, the cross-render persistence, and the outer-context dep-subset gate are each proven load-bearing). NOTE: the end-to-end render_with_diff win is bounded by the (uncached) html5ever-parse + VDOM-diff phases (Amdahl); this lever optimizes the render half cited as the dominant cost in #1967.

  • Parsed VNode subtree cache — reorders of unchanged loop items skip html5ever-PARSE too, not just render, flag-gated default-OFF (#1970). Extends the #1967/#1969 per-item RENDER cache to ALSO cache the PARSED VNode subtree per item, keyed by the SAME content-hash, under the SAME LIVEVIEW_CONFIG['loop_render_cache_enabled'] flag + the SAME two cacheability gates. The render cache cut the loop-render phase but the html5ever-parse + VDOM-build phases are ~60% of render_with_diff (#1969's render-only end-to-end win was Amdahl-bounded to ~6-11%); this reaches that bigger half. Mechanism: LoopRenderCache (crates/djust_templates/src/loop_cache.rs) gains a second map (content-hash u64 → parsed Vec<VNode>) + a per-render item manifest. For a parse-cache HIT on a foster-parenting-SAFE item (the item's rendered root tag is NOT a table/select-family element — tr/td/th/tbody/thead/tfoot/caption/colgroup/col/option/optgroup), the Node::For arm emits a tiny <dj-pc-<nonce> h=...> placeholder (a per-render random nonce in the tag name) instead of the item's HTML, so the assembled string html5ever parses is a SHORT reduced form; render_with_diff/render_binary_diff then splice the cached parsed subtrees back into the placeholders (djust_vdom::splice_loop_placeholders) and re-assign every dj-id by a pre-order re-walk. The dj-id hazard + strategy: dj-ids are purely positional (the parser assigns next_djust_id() pre-order), so a cached subtree's baked ids are position-WRONG when reused elsewhere — naive verbatim reuse duplicates ids ([0,1,2,3,4,1,2] for a 2-of-3 identical-content list). The fix re-walks the ASSEMBLED tree from the same id-counter base the full parse would use (0 for an initial parse_html, max(old_ids)+1 for a continuing parse_html_continue after the #1550/#1552 bump), reproducing a fresh full-parse's ids byte-for-byte — so the assembled VDOM, every patch (Insert/Replace embed the new node), and last_vdom are identical to the cache-OFF path. The foster-safe gate keeps <dj-pc> out of table/select containers (where html5ever foster-parents it out, destroying structure); foster-unsafe containers, multi-root items, and any splice anomaly (placeholder cache miss / found-count mismatch / a residual dj-pc-* sentinel) fall back to a full parse — always correct, no parse win for that render. Security (sentinel forgery, the adversarial-review 🔴): the placeholder sentinel tag carries a per-render random nonce (dj-pc-<nonce>) so a loop item that renders a literal unescaped <dj-pc ...> element via |safe/mark_safe — alongside a sibling that emitted a real placeholder — can neither be mistaken for a placeholder (which would strip it + corrupt the reconstructed HTML) nor splice a different cached item's subtree into its position via a crafted h= (content-confusion); reconstruction + splice match ONLY the current render's nonce tag, and parse-cache eligibility additionally refuses any item whose rendered HTML contains the literal sentinel prefix (belt-and-braces). Without the nonce, the bug stripped the user's <dj-pc> (cache-ON) while cache-OFF preserved it — a byte-identity violation for raw-HTML loops. VNode.attrs now serialize in SORTED key order (djust_vdom::serialize_attrs_sorted) so the patch wire format is deterministic — a plain HashMap serializes in nondeterministic bucket order, which the parse-cache path (assembling a node via a different parse than the cache-OFF full parse) would otherwise surface as an ON-vs-OFF patch-JSON diff. Default OFF (rides the #1967 flag, split-foundation #1122); when off, byte-identical to before. Per-phase reorder bench (median over 60 distinct shuffles, render_with_diff): N=50 parse 0.145→0.113 ms (-21.9%) / total 0.430→0.339 ms (-21.3%); N=500 parse 1.394→1.159 ms (-16.8%) / total 4.037→3.399 ms (-15.8%) — beating #1969's render-only win by also cutting the parse phase. Correctness proven: byte-identity (html + patches + version) cache ON == OFF across plain/keyed/dj-if/cycle/nested/tuple/div/table/select/multi-root templates × initial/reorder/change/append/remove for BOTH render_with_diff and render_binary_diff (the dj-key reorder round-trip — post-diff dj-ids/dj-keys match cache-off exactly — is the load-bearing case); a parse-count probe (loop_parse_cache_hits()/loop_parse_cache_misses()) asserts a reorder of N unchanged keyed items is N parse hits / 0 re-parses and an append re-parses only the new item; gate-off (#1468) confirms neutering the dj-id re-walk fails 6 byte-identity cases AND neutering the nonce (bare-prefix sentinel) fails the 3 sentinel-collision security cases. New cases in TestParseCacheByteIdentity1970 / TestParseCountProbe1970 / TestParseCacheSentinelCollision1970 (python/djust/tests/test_loop_render_cache_1967.py), the parse_cache_1970 module in crates/djust_templates/tests/test_loop_render_cache_1967.rs, and crates/djust_vdom/tests/test_loop_parse_cache_1970.rs (literal_unnonced_dj_pc_is_not_spliced + the bare-prefix gate-off).

Fixed

  • dj-virtual now ships a real layout contract and self-heals across server-driven re-renders — the windowed list scrolls inside a display:flex container and survives a live-changing {% for %} source (#1988, #1989). Two entangled dj-virtual gotchas hit in downstream production chat/feed builds. (#1988 — layout) setup() gave the injected shell position: relative (which does NOT remove it from flow — transforms are a paint-time effect per spec), so the shell's own rendered rows double-counted against the spacer and left ~400px of dead space past the last item (container.scrollHeight ≠ spacer height); and the spacer had no flex-shrink, so inside a display:flex container its explicit style.height was crushed to offsetHeight: 0 (default flex-shrink: 1) and the list silently never scrolled. The shell is now position: absolute; top/left/right: 0 (out of flow → only the spacer defines scroll height; translateY windowing preserved, container is made a positioned ancestor) and the spacer is flex-shrink: 0 (its height survives a flex parent). (#1989 — integration) A [dj-virtual] container had no reconcile path with normal server re-renders: the server always renders the full raw list (no notion of client virtualization), so a full re-render reverted the container's children back to the raw list and initVirtualLists no-op'd forever (it tracks setup state in a WeakMap keyed on the container, whose identity is unchanged) — permanent no-op, recoverable only by manual teardownVirtualList + re-init; and a single appended row landed as a loose child OUTSIDE the shell/spacer wrapper, leaking as a stray sibling whose finalize patch never applied (stuck stream). Both are now self-healing after every VDOM morph: initVirtualLists / refreshVirtualList DETECT a clobbered shell/spacer (detached or repurposed, marker attributes gone) and transparently re-virtualize against the fresh children (order-independent — whichever runs first heals), and loose element children are auto-absorbed into the item pool (at the tail) so they render inside the shell and receive subsequent patches. Absorb is append-only (correct for chat/feeds); keyed mid-list inserts/removals, differ-level dj-virtual awareness, out-of-window finalize-patch landing, and automatic stream_append__djVirtualItems wiring are deferred to follow-up #2017. Client-only change in 29-virtual-list.js (+231 B gzipped). 5 new regression cases in tests/js/virtual_list.test.js (shell/spacer style contract; full-revert self-heal via the reinit path AND via refreshVirtualList alone; loose-child absorb; intact-list no-op) — all gate-off verified. JSDOM has no layout engine, so these pin the CSS contract and reconcile behavior, not computed pixels; real-browser pixel verification (scrollHeight parity, spacer offsetHeight under flex) is a recommended manual follow-up.

  • The streaming-Markdown demo now actually streams, its Stop button works, and async background work runs on the converged WS path (#2001, #2002). Three entangled bugs in the framework's own shipped demo (examples/demo_project/djust_demos/views/markdown_stream_demo.py): (1) #2002 — mutate-and-return does not stream. _stream_chars was a sync @background loop doing self.llm_output += ch; _run_async_work awaits the callback to completion and only re-renders AFTER it returns, so the client saw the whole reply in ONE frame — despite a comment claiming a VDOM patch per char. Rewrote it as an async def that pushes each token with await self.stream_to(..., html=render_markdown(...)), bracketed with stream_start/stream_done + a finally settle; the target <article> now carries dj-stream="md_stream" dj-update="ignore" so the stream ops and the event-completion render don't both write the region. (2) #2001 — cancel_async name mismatch + non-interruptible sync loop. reset() called cancel_async("md_stream") but @background registered the task under func.__name__ == "_stream_chars", a silent no-op; and a sync @background loop can't be interrupted mid-run. Now scheduled via start_async(self._stream_chars, name="md_stream") (names match) and async (the loop yields between tokens so reset flips streaming=False mid-stream). cancel_async's docstring now states the mismatched-name no-op and the sync-body limitation. (3) Framework fix (#1646 parallel-path drift): the LIVE WS-event async-work executor runtime.py:ViewRuntime._execute_async_task (post ADR-022 convergence, not the websocket.py:_run_async_work the issues cite) unconditionally wrapped callbacks in sync_to_async, raising TypeError for an async callback — so async @background/start_async silently failed on the converged path. Mirrored the consumer twin's iscoroutinefunction check (await async callbacks directly). Docs: streaming-markdown.md's example rewritten to explicit per-chunk stream_* + cross-linked to streaming.md; both guides now document the dj-update="ignore" rule for streamed targets and the stream_to()-without-html= full-template-re-render caveat. Tests in test_markdown_stream_demo_2001_2002.py drive a real WebsocketCommunicator + runtime path (#1650): the fixed pattern emits >1 content stream op, an in-suite gate-off sibling proves plain mutation emits 0, plus dj-update=ignore exclusion, cancel_async name-match semantics, and a source-pin on the shipped demo; gate-off (#1468) verified — reverting the runtime coroutine check makes the streaming tests RED.

  • dj-window-* / dj-document-* handlers on content that appears via a later patch now bind (they were silently dead) (#1996). A dj-window-keydown.escape="close" (or any dj-window-* / dj-document-* attribute) on an element that entered the DOM via a server-driven patch — e.g. inside a {% if %} that became true, such as a command palette or inline editor — never fired: no console warning, no exception, the handler was just dead. Root cause: _scanScopedElements() (the only code that populates the scoped-listener registry) was called exclusively from the one-shot _installScopedDelegation(), so after first mount nothing re-scanned; _sweepOrphanedScopedListeners() removed registry entries for elements that left the DOM but nothing symmetrically added entries for elements that just entered via a patch. bindLiveViewEvents() now calls _scanScopedElements() on every invocation (mirroring the per-bind rescan dj-shortcut / dj-click-away already do), while the window/document addEventListener install stays one-shot inside _installScopedDelegation() — so no duplicate listeners accumulate, and the per-element alreadyRegistered check prevents double-registration / double-fire. Moving the scan out of the one-shot install made the bundle 25 B smaller gzipped. 5 JS tests in tests/js/dj-window-rescan-1996.test.js (patch-in reproducer, dj-document-keydown patch-in, no-double-register across repeated binds, single-fire with no duplicate window listeners, cleanup-still-works for a patch-removed element); gate-off (neuter the rescan + rebuild) turns 4/5 RED including the primary reproducer.

  • Two form-field value-preservation gaps in the VDOM patch path, fixed together with two consistent, opposite-polarity declarative attributes (#1990, #1991). (1) dj-force-value — clear/overwrite a still-focused field (#1990). morphElement() (the real function; the issue cites the old name morphNode) skipped the server value sync for a focused input/select/textarea unless its name changed, so a handler that cleared a still-focused composer — the Enter-to-send path, where Enter never blurs — could never take effect (the sent text stayed in the box). A field carrying the opt-in dj-force-value attribute now applies the server value even while focused. The check is lazy (evaluated only when the field would otherwise be skipped) and conservative (fires only when the attribute is explicitly present, so every other focused field keeps its typing protection); covers INPUT/SELECT/TEXTAREA. (2) dj-update="ignore" — per-field opt-out from the broadcast textarea sweep (#1991). Every push_to_view broadcast unconditionally reset every <textarea>.value in the LiveView root (the #1601 sweep, scoped too broadly), so a peer's message in an unrelated conversation wiped a user's unsent draft. A textarea marked dj-update="ignore" (already the "client-owned, don't update" convention honored by the per-node morph) is now skipped by the sweep. Both broadcast-sweep call sites — 02-response-handler.js's applyPatches path and 12-vdom-patch.js's preserveFormValues innerHTML path — route through one shared syncBroadcastTextareas helper, so the opt-out lives in exactly one place (parallel-path-drift cure, #1646). +50 B gzipped; 11 JSDOM cases in tests/js/form_value_preservation_1990_1991.test.js (direct helper, the real production broadcast path via handleServerResponse, an anti-drift pin that both sweep sites route through the helper, and INPUT/TEXTAREA focus cases using real document.activeElement), gate-off verified (#1468) on both fixes. Documented in docs/website/guides/declarative-ux-attrs.md.

  • dj-input.debounce-N (and any .lazy / .debounce suffix on a non-dj-model directive) now warns in debug mode instead of silently never binding (#1999). Only dj-model parses the .lazy / .debounce-N in-name modifier from its attribute name; dj-input / dj-change / dj-click debounce via the separate standalone dj-debounce="N" attribute. Because a dot is a legal attribute-name character, dj-input.debounce-200="search" is one literal attribute that no [dj-input] selector matches — so the input never bound, with no console error and nothing pointing at the cause (the dj-model.debounce-300-works mental model made it read as a plain non-working feature). bindLiveViewEvents now runs a debug-gated scan (_warnUnrecognizedDjModifiers, zero cost outside window.djustDebug) that emits a console.warn naming the offending attribute and the standalone-dj-debounce fix. Deliberately scoped to the .lazy / .debounce modifiers on non-model directives — other legit dotted conventions (dj-keydown.enter, dj-window-keydown.escape, dj-loading.class / .show / .hide / .disable / .for) are untouched. The dj-model guide now documents the divergence side-by-side. +315 B gzipped; 7 JS tests in tests/js/dj-input-modifier-warning-1999.test.js (gate-off verified).

  • TenantMixin.set_tenant() lets a WebSocket event handler switch the current tenant from a fresh/default session, and the session resolver's WS-persistence semantics are now documented (#2003). Two undocumented frictions with the session tenant resolver: (1) SessionResolver.resolve() is read-only — it never writes request.session, and there was no set_tenant helper anywhere, so switching tenant over a WS event meant hand-writing request.session[...] with no built-in save guarantee (a LiveView event has no HTTP response for Django's SessionMiddleware to persist against); and (2) TENANT_REQUIRED (default True) is enforced in dispatch()/get()/post() before mount(), so a fresh session 404s before mount() can resolve a default. Adds TenantMixin.set_tenant(tenant_id) (inherited by TenantScopedMixin): it updates the authoritative in-memory view state (self.tenant) and best-effort mirrors the id into request.session[TENANT_SESSION_KEY] only when a session resolver is configured (a no-op for subdomain/path/header/custom). The SessionResolver docstring + the Multi-Tenant guide now document that view state is the WS-lifecycle source of truth, the session write is a mirror only, and that tenant_required=False + manual resolution in mount() is the correct pattern when a fresh session has no tenant yet. 12 cases in TestSetTenant / TestTenantScopedMixinExposesSetTenant (test_tenant_set_tenant.py), incl. gate-off sentinels for the session-resolver mirror and the non-session no-op.

  • A private (_-prefixed) attr holding a Django model no longer comes back as a plain dict after a state round-trip — it re-hydrates as the model (#1994). A model cached on a private attr (e.g. self._workspace = Workspace.objects.get(...) in mount()) is persisted to the session so it survives the HTTP-POST-fallback restore path — which does NOT re-run mount(). That path ran normalize_django_value over private state (the client-facing serializer), turning the model into the lossy {"pk", "__str__", <fields>} dict, so on restore self._workspace was a dict and self._workspace.memberships raised AttributeError (the reported traceback at mixins/request.py post()). Private state is server-side view cache (never sent to the client), so the fix encodes each model as a re-hydratable ref {"__djust_model_ref__": "<app>.<model>", "pk": ...} (recursing into nested dicts/lists) in _get_private_state(), and re-fetches it from the DB in _restore_private_state(). A ref whose row was deleted between save and restore re-hydrates to None with a warning (a stale cached model must not hard-crash a reconnect). 5 tests in test_private_model_roundtrip_1994.py: model-comes-back-as-model (gate-off sentinel), nested-in-dict, in-list, deleted→None, non-model attrs unaffected.

  • Docs: three copy-from-and-it-breaks documentation corrections (#2000, #2004). (a) PresenceMixin's module docstring showed a flattened presence record ({{ p.color }} / {{ p.name.0 }} / presence['name']), but every backend nests the caller-supplied meta under a "meta" key — the record is {"id", "joined_at", "meta": {...}}, so the correct access is p.meta.name / presence['meta']['name']. Following the docstring verbatim produced a KeyError / silently-empty output. Docstring corrected + the record shape documented (#2000). (b) {% djust_markdown %} requires DjustTemplateBackend (it registers only with djust's Rust engine, not Django's stock backend); a plain django-admin startproject TEMPLATES setup raises TemplateSyntaxError: Invalid block tag 'djust_markdown' even with {% load live_tags %}. Now stated where readers copy the tag from (docs/website/guides/streaming-markdown.md). (c) dj-transition-group's "you author the CSS — this ships none" caveat (already noted for dj-transition) is now repeated in the dj-transition-group quick-start, where readers actually copy the class names from (docs/website/guides/declarative-ux-attrs.md) (#2004).

  • Two upload-config gotchas: LiveView runtime keys set in DJUST_CONFIG are now honored (not silently ignored), and the default upload chunk size no longer exceeds the default frame limit by 21 bytes (#1993). (1) LiveViewConfig._load_from_settings() (python/djust/config.py) only read LIVEVIEW_CONFIG, so a max_message_size / rate_limit / event_security set in the similarly-named DJUST_CONFIG dict (which already backs tenancy/presence/state-backend, and is easy to confuse with LIVEVIEW_CONFIG) was a silent no-op — e.g. raising the limit via DJUST_CONFIG = {"max_message_size": 262144} did nothing, no error, no warning. It now falls back to DJUST_CONFIG for keys that are genuine LiveView config keys (present in the defaults, so unrelated tenancy/presence keys aren't pulled in), with LIVEVIEW_CONFIG winning on a collision and a debug breadcrumb naming each adopted key. The misleading comment that implied DJUST_CONFIG was already handled here is corrected. (2) The upload client's DEFAULT_CHUNK_SIZE was 64 * 1024 — exactly the max_message_size default (65536) — and every chunk frame prepends a 21-byte binary header (buildFrame), so 65536 + 21 = 65557 > 65536: a brand-new project using only allow_upload(...) with default settings failed any upload past a fractional first chunk with Message too large (65557 bytes). Reduced to 63 * 1024 (64512 payload + 21 header = 64533 < 65536). 4 Python config tests (gate-off verified) + a JS source-invariant pin in tests/js/uploads.test.js (DEFAULT_CHUNK_SIZE + FRAME_HEADER_BYTES ≤ 65536).

  • {% djust_markdown %} on a code-only artifact (a complete fenced block with no trailing newline) no longer splits the closing ``` off as an escaped provisional paragraph (#1998). The provisional-line splitter (split_provisional, crates/djust_templates/src/markdown.rs) treats a trailing line with an ODD backtick count as an unterminated inline-code span. A lone closing ``` has 3 backticks (odd), so for a complete but newline-less fence — where inside_unclosed_fence correctly reports the fences as balanced — the closing ``` was split off and re-rendered as <p class="djust-md-provisional"> `</p>` instead of completing the `<pre><code>` block. (This is why a chat transcript — prose + fence, always followed by more content or a trailing newline — highlighted code fine, while a "code artifact" panel rendering *just* the code body did not.) Fix: a one-line guard — a trailing line that is itself a ` fence delimiter completes a balanced fence (the count is even), so keep the whole block stable. Rust unit + render tests + a Python end-to-end test (test_markdown.py`); gate-off verified (reverting the guard reopens the provisional-paragraph split).

  • LiveView.set_changed_keys() now accepts a zero-arg form to force a re-render when a handler changed only external state (a DB row) and no public self.* attr (#1992). A handler that mutates only the database — e.g. msg.save(update_fields=["active_child_id"]) — and assigns no public attribute produced NO re-render: auto change-detection (_snapshot_assigns) saw nothing changed and auto-skipped the event, even though get_context_data() re-queries the DB and would render different HTML (the client kept showing stale content). set_changed_keys("attr") existed (#1981) but required naming a changed attr; there was no way to say "nothing on self changed, just re-render". Calling set_changed_keys() with no arguments now forces a full re-render via the existing _force_full_html bypass without naming a key. 4 tests in test_set_changed_keys_zero_arg_1992.py on the REAL ViewRuntime.dispatch_event path (the test client bypasses the pre==post skip and would hide the bug, #1650) — including a gate-off baseline (the same DB-only mutation minus the zero-arg call auto-skips → noop; adding the call renders), so the render is attributable to exactly the zero-arg hatch (#1468).

  • The redis / tenants-redis extras (and the dev group) now pin redis>=5.0.0,<8 — redis-py 8.x crashes the canonical channels_redis production setup (#1995). channels_redis's receive loop blocks on bzpopmin(timeout=5); redis-py 8.0 changed socket-read-timeout handling on that path, and the resulting redis.exceptions.TimeoutError is uncaught inside the ASGI consumer — so a djust deployment following the docs verbatim (channels_redis for CHANNEL_LAYERS, required for push_to_view/presence/cursor/cross-process) shows a flashing "reconnecting" banner every few seconds under multi-process load. The constraint was >=5.0.0,<9 in all three sites (pyproject.toml [redis] + [tenants-redis] extras + dev group), too permissive — it allowed 8.x. Pinned <8 (verified: redis>=5,<8 gives 0 errors in a soak that previously failed within seconds). Defensive tightening — the lock already resolved to 6.4.0, but <9 let a future re-lock drift to the crashing 8.x.

  • Nested dict/list access in templates ({{ block.content.text }} on a JSONField) no longer renders silently empty — Django _resolve_lookup parity (#1997). Context::resolve's lazy sidecar getattr walk (crates/djust_core/src/context.rs) did getattr ONLY at each segment, so a dict/list intermediate reached mid-path resolved to empty with no error: for {{ block.content.text }} where content is a JSONField (a plain dict), getattr(dict, "text") raises AttributeError and was swallowed → missing output, zero signal. Django's Variable._resolve_lookup tries dict item access → attribute → integer list-index at every segment; djust only did the middle step on the sidecar path (the eager Context::get path was already dict-aware — #1646 parallel-path drift). The walk now mirrors Django's order (get_itemgetattrget_item(int)). The #1986 serialization-floor proxies implement no __getitem__, so item access on them falls through to the floored getattr — verified no floor bypass. New test_nested_resolve_1997.py (dict value, list index, list→dict, dict-key-wins-over-attribute, missing-key-empty, floor-not-bypassed), gate-off verified.

  • In-place-mutation remedy advice was broken, and the #1678 kanban fixture's card-move step was a vacuous guard (#1981). The _snapshot_assigns list-≥100 and dict-≥50 fingerprint-truncation warnings told developers to call self.set_changed_keys({...}) — a method that did not exist — and the docstring pointed to self._changed_keys, which the pre/post skip renders ineffective. The method now exists (see Added) so the advice is accurate, and the docstring is corrected to point to it / an immutable update. Separately, the #1678 client-faithful VDOM fixture's step 2 (cross-column card move) captured 0 patches because KanbanTabsView.move_card mutated columns in place — a regression guard that exercised nothing. move_card now does an immutable update, so the step drives a real targeted diff (RemoveChild + InsertChild + count-badge SetText); the fixture was regenerated and the freshness gate (#1979) pins the meaningful output.

  • HTML preserve-block regexes now match end tags with trailing whitespace (</script >, </style\n>) — CodeQL py/bad-tag-filter #2482. _strip_comments_and_whitespace() (mixins/template.py) masks <script>/<style>/<pre>/<code>/<textarea> raw-text blocks behind placeholders before the HTML-comment-strip + whitespace-collapse passes, so their bodies aren't corrupted. The end-tag patterns used a bare </tag>, but per the HTML5 tokenizer an end tag closes on </tag followed by whitespace, /, bogus attributes, or > — so </script >, </script\n>, and even </script bar> all close a <script> in a browser. The bare pattern missed those forms, so the block was NOT preserved, letting a comment-looking token inside the JS/CSS body (var s = '<!-- x -->') get stripped and the script corrupted. All five patterns now use </tag[^>]*> (CodeQL's recommended form, matching every close variant). New TestEndTagWhitespacePreservation (whitespace + newline + bogus-attribute cases, gate-off verified) in test_strip_whitespace.py.

  • _run_async_work no longer writes against a stale view on disconnect/re-mount mid-await (#1940). LiveViewConsumer._run_async_work runs as a detached ensure_future task that captures view = self.view_instance before its first await (the background callback). If a disconnect (which nulls view_instance) or a live_redirect / re-mount (which reassigns view_instance to a NEW view) interleaved during that await window, the completed task ran its handle_async_result + _sync_state_to_rust + render_with_diff + source="async" frame against the torn-down or replaced view — a pre-existing untested race (#245/#1198 TOCTOU class). Added an identity-guard after the callback await on both the success and error paths: if the consumer's live view is no longer the captured one, the stale re-render is dropped. Cancellation can't stop the in-flight worker thread (sync_to_async runs in a thread pool), so an identity-guard — not task cancellation — is the correct cure. The normal (no-teardown) async-work path is byte-identical. New cases in TestRunAsyncWorkTeardown (python/djust/tests/test_run_async_work_teardown_1940.py), gate-off verified.

  • TutorialMixin now initializes its four internal tutorial-signal attrs in __init__, not the tutorial_total_steps setter (#1952). _tutorial_active_target, _tutorial_active_class, _tutorial_skip_signal, and _tutorial_cancel_signal were previously initialized inside the tutorial_total_steps SETTER, so a TutorialMixin view that never set tutorial_total_steps (or read those attrs before the setter ran) hit AttributeError — e.g. _cleanup_active_step() (called from start_tutorial's finally block) reads _tutorial_active_target/_class, and skip_tutorial/cancel_tutorial read the skip/cancel signals once running. The four attrs now default to None in __init__ (placed alongside the existing _tutorial_running/_tutorial_current_step/_tutorial_total_steps inits); the setter keeps its sole job of updating _tutorial_total_steps. Surfaced by ADR-023 M4d typing (PR #1951), left untouched then per #1079 typing-PR scope. New regression cases in TestSignalAttrsInitializedInInit (read the four signals without invoking the setter; pre-fix raised AttributeError).

  • ComponentMixin.update_component no longer raises AttributeError on a LiveComponent (#1947). update_component(component_id, **props) calls component.update(**props), but LiveComponent (a subclass of ContextProviderMixin, NOT Component) had no update() method at runtime, so any LiveComponent that did not define its own update() raised AttributeError on that path (the latent bug annotated with a # type: ignore[attr-defined] in ADR-023 M4c, part 1). LiveComponent now has a base update(**kwargs) that sets each prop as an instance attribute (mirroring the Python/hybrid path of Component.update) and returns self for chaining — the same component.update(**props) API the component docs already document. Subclass update() overrides still take precedence. The stale # type: ignore[attr-defined] at the call site is removed. Regression coverage in TestUpdateComponentNoUpdateOverride (tests/unit/test_component_parent_communication.py): bare-LiveComponent update through update_component, base-update() chaining returns self, subclass-override-still-wins.

  • Dev-env: detect + recover the core.bare = true shared-config corruption that breaks worktree + main checkout (#1938). A linked git worktree shares one .git/config with the main checkout; if anything flips core.bare to true there (a build/PyO3-repoint step running git config core.bare true, an IDE/GitKraken integration, or a stray manual command — the #1804/#300 pattern), git status/git push break in BOTH trees (every tracked file shows as deleted). An exhaustive audit confirmed no djust pre-push hook, test, or script writes core.bare — every in-repo git operation is read-only (git status/diff/grep/ls-files/rev-parse) or scoped to an isolated tmp dir (test_run_with_venv_python.py, test_git_commit_with_precommit.py, test_deploy_cli.py's git init fixtures), and all three were verified empirically to leave core.bare unchanged — so the corruption is external, not a framework bug. New scripts/check-shared-git-config.sh reads core.bare from the SHARED config (resolved via --git-common-dir, works from any worktree), reports a leak (exit 1), and with --fix performs the documented recovery (core.bare false); it NEVER writes core.bare true. The worktree-subagent mitigation (push --no-verify; CI is the authoritative gate) plus the detector are documented in CONTRIBUTING.md "Working in a git worktree". Tested by 5 cases in tests/test_check_shared_git_config.py (build a throwaway main+worktree, simulate the leak in the throwaway shared config, assert detect + --fix-recover + the never-writes-true invariant; gate-off self-tested per #1468).

  • Real type gaps surfaced flipping management/checks/auth/templatetags + loose modules to strict (ADR-023 M4d, group 1). None changed runtime behavior; each removes a latent contract lie. Convergence dividend (one real annotation bug, fixed): mixins/request.py's _streaming_iter was annotated AsyncIterator[str] but yields the ChunkEmitter's bytes chunks (the emitter encode("utf-8")s every chunk before queueing, and StreamingHttpResponse is fed bytes) — the strict typing of http_streaming.ChunkEmitter._aiter_impl() -> AsyncIterator[bytes] exposed the mismatch in the (already-strict M4c) mixins/request.py; corrected to AsyncIterator[bytes]. Other gaps (annotation-only, no behavior change): checks/security.py check_security's S002 @csrf_exempt scan read node.body[0].value.value (an ast.Constant.value, a str | bytes | int | … union) and called .lower() on it — guarded with isinstance(doc, str) so a non-str first-statement constant can't AttributeError (it never matched "csrf" anyway); _decorator_callable_name typed Optional[str] so the _is_permission_required_decorator comparison stops leaking Any. auth/core.py check_view_auth's login_url (a getattr(...) or getattr(...) over unstubbed Django) cast to str for the _check_django_access_mixins(login_url: str) contract; check_redis (djust_doctor) returns Optional[_CheckResult] (it returns None to skip the non-Redis path). Plus bool(...)/str(...)/cast(...) boundary narrowing at the Django-untyped surface (user.has_perms(...), apps.is_installed(...), self.style.SUCCESS(...), json.loads(...), template.render(...), click.prompt(...)) and dict[str, Any]/list[CheckMessage] var annotations where mixed-type literals were inferred too narrowly. cleanup_liveview_sessions imports the session helpers from their canonical source (djust.session_utils) instead of the live_view re-export so the strict island resolves them (equivalently exported via live_view.__all__).

  • Real type gaps surfaced flipping the theming/ subpackage to strict (ADR-023 M4c, part 3). None changed runtime behavior; each removes a latent contract lie, verified rendering byte-identical. theming/manager.py: ThemeState.pack was annotated str with a None default (a dataclass field lying about nullability — get_state() returns pack=None when no pack is configured), which also made the ThemeState(pack=pack) construction an [arg-type] error against str | None; corrected to str | None = None. theming/_registry_accessor.py: the ThemeRegistry singleton's _presets/_themes/_packs/_manifests/_discovered were assigned only through a local inst in __new__, so mypy saw 25 [attr-defined]/[has-type] errors at every access in _registry_accessor + registry — declared them as class-level annotations (dict[str, Any] / bool), the canonical singleton-attr fix (the attrs are still populated once per process in __new__). theming/theme_css_generator.py + theming/pack_css_generator.py: self.ds / self.pack were DesignSystem | None / ThemePack | None (the get_design_system/get_theme_pack return type) but __init__ raises when None, so every later self.ds.typography / self.pack.icon_style access was a union-attr error (14 in pack, 8 in theme) — narrowed by assigning the post-raise non-None value to a self.ds: DesignSystem / self.pack: ThemePack annotated attr. theming/manager.py: the two CompleteThemeCSSGenerator reassignments (in generate_critical/deferred_css_for_state) collided with the inner ThemePackCSSGenerator gen var's inferred type ([assignment] + a phantom [attr-defined] on generate_critical_css) — renamed the inner var pack_gen. theming/palette.py: s_h/a_h mixed int (from hex_to_hsl) and float (from params[hue_offset] % 360, where _MODE_PARAMS is inferred dict[str, float] because it mixes sat_scale=0.85 with integer hue offsets) — wrapped the (always-integer) hue offsets in int(...) to keep s_h/a_h int (int(180) % 360 is identical). theming/build_themes.py: build_all declared -> Dict[str, str] but artifacts["individual_themes"] is a list[str] — the return type lied about the heterogeneous shape; corrected to Dict[str, Any] (+ the manifest/artifacts locals annotated). theming/accessibility.py: a float ** float returned Any ([no-any-return]) and a bool-or chain over unstubbed-attr comparisons returned Any — wrapped in float(...)/bool(...). theming/mixins.py: the conditional-import event_handler fallback was an unguarded [no-redef] (narrow # type: ignore[no-redef]), _theme_manager was ThemeManager with a None default (corrected to ThemeManager | None), and the four event handlers gained if self._theme_manager is None: return guards so the _theme_manager.set_mode(...) accesses type-check (no-ops post-mount, matching _setup_theme_context's existing guard). Plus several mark_safe(...)/config-.get(...)/cookie-.get(...) Any-leaks narrowed at the boundary (cast(str, ...) / str(...)).

  • Real type gaps surfaced flipping the admin_ext/ subpackage to strict (ADR-023 M4c, part 2). None changed runtime behavior; each removes a latent contract lie, verified behavior-identical by the admin test suite. admin_ext/views.py: LoginView.update_username / update_password had implicit-Optional defaults (field: str = None) that PEP 484 prohibits — corrected to Optional[str]; and ModelCreateView.mount overrode ModelDetailView.mount(self, request, object_id=None, ...) with a narrower mount(self, request, **kwargs) signature (an LSP [override] violation) — restored the object_id parameter (still forced to None internally, so the create view's "always start with no object" behavior is unchanged) so the override is contract-compatible. admin_ext/options.py: several warn_return_any leaks at the Django-untyped boundary narrowed at the return site — _widget_has_permission returns bool(user.has_perms(...)), get_form pins modelform_factory(...) to a typed local, and get_field_display_name wraps the verbose_name/short_description reads in str(...). admin_ext/plugins.py: NavItem.has_permission / AdminWidget.has_permission / AdminWidget.render similarly narrowed (bool(request.user.has_perm(...)), str(render_to_string(...))). admin_ext/__init__.py autodiscover and admin_ext/apps.py DjustAdminConfig.ready gained explicit -> None returns.

  • Real type gaps surfaced flipping the mixins/ subpackage to strict (ADR-023 M4c, part 1). None changed runtime behavior; each removes a latent contract lie or surfaces a latent bug for follow-up. Latent bug (annotated, NOT fixed — out of scope #1079): mixins/components.py ComponentMixin.update_component calls component.update(**props) after isinstance(component, LiveComponent), but LiveComponent (which subclasses ContextProviderMixin, NOT Component) has no update method at runtime — confirmed via the live MRO (Component.update exists; LiveComponent.update does not). So update_component() would raise AttributeError if ever invoked with a LiveComponent. The strict flip carries a narrow # type: ignore[attr-defined] with a comment at the call site; the fix (move update onto LiveComponent, or change the routing) is left for a dedicated bugfix PR since mixins/ M4c(1) is annotation-only. Other gaps (annotation-only, no behavior change): mixins/page_metadata.py _pending_page_metadata / _drain_page_metadata were List[Dict] (incomplete generic) → List[Dict[str, str]]. mixins/model_binding.py allowed_model_fields class attr was inferred None (from = None) → annotated Optional[List[str]] (the true subclass-override contract); _dj_model_fields bare frozensetfrozenset[str]. mixins/rust_bridge.py rendered_context = {} was inferred Dict[str, dict[str, Any]] from its first (dict-valued) assignment, breaking later primitive/str assignments → annotated Dict[str, Any] (no logic change; the change-detection path is byte-identical). mixins/template.py pos = open_pos + 4 mixed the float("inf") sentinel into an int accumulator → narrowed int(open_pos) in the branch where open_pos < close_pos guarantees a real int; _current_html_size/_previous_html_size declared Optional[int] to match the getattr(..., None) first-render seed. mixins/jit.py _variable_extraction_cache was Dict[str, dict] but stores Optional[dict]Dict[str, Optional[dict]]; the if not extract_template_variables truthy-function check (a function is always truthy) → is None.

  • Real type gaps surfaced flipping the FINAL components/ modules to strict (ADR-023 M4b, part 3). None changed runtime behavior; each removes a latent contract lie, verified rendering byte-identical. components/components/button.py + components/ui/list_group_simple.py: Dict[str, any] (the builtin any function used as a type — a typo) corrected to Dict[str, Any]. components/ui/modal_simple.py: Modal._render_custom read self.show but __init__ never assigned it (the show= kwarg was passed to super().__init__ but not re-set as an instance attr like its siblings body/title/etc.) — [attr-defined] against the RustModal-instance path; added self.show = show to match the established pattern (byte-identical: in the Python-fallback render path the base already set it via its kwargs loop). components/components/prompt_editor.py: self.template reads were typed Optional[str] (the base Component.template: Optional[str] class attr) while the subclass always sets a str — narrowed via a template = self.template or "" local at the top of _render_custom. components/ui/navbar_simple.py: the nav-items param was annotated List[Dict[str, Union[str, bool, List[...]]]] which mis-typed the nested-dropdown access as non-iterable (union-attr on .get/__iter__) — widened to List[Dict[str, Any]] (the honest contract for heterogeneous dynamically-accessed dicts, #1108). Float/int local-init mismatches in chart/heatmap/pivot renderers (total = 00.0, y = ...y: float = ..., row_total/col_totals/grand_total → float) where a numeric accumulator was seeded int then +='d a float (output via :.1f/_format_val is identical). components/gallery/registry.py: cat = info.get("category", "misc") was object-typed (from the heterogeneous EXAMPLES literal) so cat.title() was attr-defined/call-overload — coerced cat = str(...) (category is always a str). Numerous list/dict locals across data_table/templatetags annotated to fix mixed-element var-annotated (e.g. pages: list mixing page ints and "...", col_items: list[list[Any]]).

  • Real type gaps surfaced flipping the components/ UI catalog to strict (ADR-023 M4b, part 2). None changed runtime behavior; each removes a latent contract lie. components/mixins/accordion.py + components/descriptors/accordion.py: AccordionState.active (and the descriptor's nested State.active) was annotated str, but in multiple=True mode it holds a list of open item ids — so actives.remove(value) / actives.append(value) / state.active = [value] were [attr-defined]/[assignment] errors against the declared str. Corrected to Union[str, List[str]] (the true runtime contract — single id when single, list when multiple), narrowing the list branch with cast(List[str], inst.active) (mixin, guarded by inst.multiple) / the existing isinstance(actives, list) (descriptor). The 8 deprecated state mixins (tooltip/tabs/sheet/modal/dropdown/collapsible/carousel/accordion) had component_id = self._resolve_component_id(component_id) reassign an Optional[str] return onto a now-str-typed param — coalesced to ... or "" (behavior-identical: _get_typed_instance("") and _get_typed_instance(None) both miss the instance dict and hit the inst is None guard). Typed all *_instances class vars (Optional[Dict[str, XState]]) and the descriptor _handle_event(self, state: "State", ...) params (the nested-State-subclass forward-ref, not the base TypedState, so state.is_visible/.active/etc. resolve).

  • Real type gaps surfaced flipping the components/ machinery to strict (ADR-023 M4b, part 1). None changed runtime behavior; each removes a latent contract lie. components/server_event_toast.py: ServerEventToastMixin.push_toast calls self.push_event(...), a method supplied by the host LiveView (via PushEventsMixin) and absent from the standalone mixin — mypy flagged [attr-defined]; declared the cooperating method under if TYPE_CHECKING: (the canonical djust mixin pattern, mirrors streaming.py), no runtime change. components/function_component.py: {% call %} dispatch set instance._slots / instance._children on a LiveComponent (per-invocation template-render attrs, distinct from the class-level slots declaration list) — narrow # type: ignore[attr-defined] with an explanatory comment; the @component decorator's _djust_* metadata stamps on a plain Callable likewise narrowed. components/presets.py: _BUTTON_PRESETS (heterogeneous str/bool values) was inferred dict[str, object], making the built-in register_preset(...) registration loop an [arg-type] error — annotated Dict[str, Dict[str, Any]]. components/mixins/base.py: TypedState.__init__ called default.fget(self) where property.fget is Optional — added the fget is not None guard (behavior-preserving for every real _make_property-built property). components/utils.py + components/icons.py + components/suspense.py + components/templatetags/_registry.py: several Any-leaks at Django boundaries (col.get(...)/value.strftime(...)/mark_safe(...)/conditional_escape(...)/render_to_string(...)) narrowed to str at the boundary so warn_return_any is satisfied without an Any escape.

  • Real type bugs surfaced flipping the loose top-level modules to strict (ADR-023 M4a). None changed runtime behavior; each removes a latent contract lie. react.py: ReactComponentRegistry._component_modules was annotated Dict[str, str] but every register() stores a nested {"module": ..., "export": ...} dict — the field annotation contradicted the (correct) return types of get_module_info() / get_all_modules(); corrected to Dict[str, Dict[str, str]]. presence.py: broadcast_to_presence(event, payload: Dict[str, Any] = None) declared a non-Optional param with a None default (the body already coalesces payload = {}) — corrected to Optional[Dict[str, Any]] = None. testing.py: assert_routed_views_allowed imported _routed_liveview_classes from djust.checks, where it is not re-exported (it lives in djust.checks.components) — corrected to import from the defining submodule (verified importable). performance.py: PerformanceTracker.root_node/current_node were inferred None-only from __init__ then reassigned TimingNode — declared Optional[TimingNode], and the _find_parent_node call now guards root_node (was guarding only current_node, but both are None/set together).

  • Real type gaps surfaced flipping the dispatch/runtime core to strict (ADR-023 M3). None changed runtime behavior; each makes the spine type-check clean and removes a latent contract lie. sse.py: SSESession._request was assigned (DjustSSEStreamView.get) and read (runtime.SSESessionTransport.build_request) but never declared in __init__ — added the Optional[Any] declaration alongside _event_request. runtime.py: _instantiate_error_frame was first-assigned None then a dict (mypy inferred None-only, so the dict assignments were errors) — declared Optional[Dict[str, Any]] in __init__; _instantiate_view called Optional[type]() ("None not callable") — added the ViewResolution.__bool__-implied view_class is None guard; the dormant actor-mount path called Optional[create_session_actor] — added the actor-availability guard. websocket.py: _recovery_html was str-typed from its first assignment but cleared to None on one-time use — annotated Optional[str]. live_view.pyi: the M2-island stub omitted the module-level _FRAMEWORK_INTERNAL_ATTRS that websocket._snapshot_assigns imports — added it (a stub-completeness gap the M3 flip surfaced because websocket now resolves the import against the strict stub). Several Any-leaks at Django/PyO3 boundaries narrowed at the boundary (bool()/str()/int() wraps on validate_host, child-render output, and the version helpers).

  • Real type bugs surfaced while building the mypy strict islands (ADR-023). security/attribute_guard.py: DANGEROUS_ATTRIBUTES was annotated Set[str] (mutable) but holds a frozenset — the annotation lied about mutability for a membership-only, never-mutated security denylist; corrected to frozenset[str], matching the immutable-denylist intent. security/log_sanitizer.py: sanitize_dict_for_log's result dict holds heterogeneous values (redacted strings, nested sanitized dicts, sanitized item lists) under an inferred dict[str, str] — annotated dict[str, Any]. security/state_snapshot.py (sign_snapshot) and permissions.py (dump_starter_document): [no-any-return] from untyped-dependency calls (TimestampSigner.sign, yaml.safe_dump) narrowed to str at the boundary. Plus annotation gaps closed in rate_limit, _context_provider, schema, permissions, and test_isolation (missing return/param annotations + var-annotated hints). None changed runtime behavior; they make the cited security/validation modules type-check clean under strict rules.

  • Real type bugs surfaced flipping the public-API quartet to strict (ADR-023 M2). decorators.event_handler: the untyped dual-call API (@event_handler bare vs @event_handler(...)) reported [arg-type] + "Self argument missing" at every bare-decorator call site (e.g. FormMixin.validate_field / submit_form) — the exact consumer-facing liability ADR-023 names; fixed with @overload so both forms type correctly for downstream consumers. live_view._is_serializable: _non_serializable was fixed to a 3-element tuple by inference, so the appended _thread.LockType (4th element) silently fell outside the declared type and the lock branch was effectively untyped — annotated tuple[type, ...]. live_view.pyi: the stream stub was missing the limit param present on StreamsMixin.stream (stub-vs-source signature drift, the #1646 class) — added. mixins/handlers.py: _handler_metadata had no base annotation, so its inferred non-optional dict conflicted with LiveView.__init__'s None init — annotated Optional[Dict[str, Dict[str, Any]]] to match the runtime contract (the is not None cache guard). decorators._ComputedProperty: custom metadata attrs (_is_computed, _computed_name, _computed_deps) were assigned but undeclared — declared as class annotations. None changed runtime behavior.

  • live_redirect to a non-LiveView path now falls back to a full-page navigation instead of stranding the page (#1934). With auto_navigate defaulting ON in v1.1, a live_redirect whose target is a plain Django view (e.g. a TemplateView) left the URL bar on the new path while the previous LiveView stayed mounted — the URL led the DOM with no swap. Two coupled client bugs in handleLiveRedirect (python/djust/static/djust/src/18-navigation.js): (1) the pushState fired BEFORE the view resolution, so the URL changed for a target that never got a DOM swap; and (2) — the load-bearing root cause found by symptom-up tracing, NOT the issue's cited "resolveViewPath returns falsy" — the resolution used resolveViewPath(), which has a container fallback that returns the CURRENT [dj-view]'s class on a route-map miss. That fallback is documented "only works for live_patch, not cross-view navigation", so for a cross-view live_redirect to a non-LiveView it returned the SOURCE view (truthy) and the client SPA-mounted the OLD view under the NEW URL — the exact reported symptom (URL /onboarding/, but the jira view mounts). The server's #1647 _resolve_view_path_from_url guard also returns None for a non-LiveView URL and keeps the stale client-supplied view, so the client must make the full-nav decision. Fix: a new STRICT resolveLiveViewPath() (route map ONLY, no container fallback) drives the cross-view decision; the pushState + URL-dependent side effects (updateAriaCurrent, scroll, before-navigate) are DEFERRED into the LiveView-resolved + WS-connected branch, so the URL never leads the DOM. A non-LiveView target (or a disconnected WS) does a full-page navigation validated through window.djust.safeNavigationTarget (mirroring the existing cross-origin branch, with the safeNavigationTarget open-redirect/javascript: guard). The popstate back-nav redirect (the #1646 twin) also switched to the strict resolver, so a back-nav to a non-LiveView reloads correctly instead of re-mounting the source view. The served minified bundle (client.min.js + .gz/.br/.map) was rebuilt to carry the fix. Reproduce-first + gate-off (#1468) verified: new cases in describe('issue #1934 …') (tests/js/navigation.test.js) — non-LiveView target: full-page nav, NO pushState, NO WS mount (strand-free), positive case: a LiveView target still SPA-mounts, and LiveView target but WS not connected: full-page nav — go RED when EITHER half of the fix is reverted (the strict-resolver call → the SPA branch fires safeNavigationTarget never called; the pushState-first order → the strand pushState assertion fails). Full JS suite green (1746 passed).

  • De-flaked TestMountAsyncAndPushDrain::test_mount_dispatches_async_work under parallel -n auto by OWNING THE COMPLETION SIGNAL instead of bounded-polling the scheduler (#1931; the async-dispatch sibling of #1930). The test mounts a view that schedules a background callback via start_async() in mount(), then asserts the callback ran (view.value == 42). The runtime dispatches that callback FIRE-AND-FORGET via asyncio.ensure_future(self._execute_async_task(...)) inside ViewRuntime._dispatch_async_work (python/djust/runtime.py:4444), and _execute_async_task ITSELF awaits a sync_to_async(callback) thread-pool round-trip before setting value=42. The original test waited for that to land via a BOUNDED poll — for _ in range(10): await asyncio.sleep(0) — a wall-clock-fragile race: under a CPU-saturated parallel loop the asyncio scheduler can fail to run the spawned task (which also competes for the thread pool) within 10 yields, so the assertion fired while value was still 0 (flaked 1/4 runs in the #1930 worktree; passed 3/3 in isolation). This is the CLAUDE.md bounded-poll-racing-a-real-scheduler class (#1830/#1815 family), NOT a code regression — the mount async-dispatch feature is correct and lands every time given enough scheduler turns. Fix (in python/djust/tests/test_transport_behavioral_parity.py, test-only — production unchanged): wrap the runtime's asyncio.ensure_future seam during dispatch_mount to capture the EXACT _execute_async_task task handle it spawns (the _flush_push_events fire-and-forget send, which uses the same primitive, is excluded by coroutine name so the gate-off stays sharp), then await asyncio.gather(*async_work_tasks) — a deterministic completion signal, no timing bound. Reproduce-first verified: shrinking the poll bound to 0–1 yields makes the OLD form fail 25/25 (proving the margin is razor-thin), while the new form passes 0/15 failures under 8-way CPU saturation. Gate-off (#1468) verified: disabling the _dispatch_async_work(None) call in dispatch_mount spawns no _execute_async_taskassert async_work_tasks fails (empty list), so the test is load-bearing on the actual async-dispatch path. 3-clean-runs gate (#1174): full suite -n auto × 3 all clean (8604 passed each). New behavior in test_mount_dispatches_async_work.

  • De-flaked the six rate-limit burst-exhaustion tests under -n auto by OWNING THE CLOCK — test_ping_flood_triggers_disconnect no longer flakes on a wall-clock token refill (#1930). TestRateLimiter + TestGlobalRateLimit (tests/unit/test_event_security.py) build test-local TokenBucket / ConnectionRateLimiter instances and assert burst exhaustion (e.g. rate=100, burst=2 → the 3rd check() must be False). TokenBucket read time.monotonic() directly, so under CPU-saturated parallel make test real wall-clock elapsed between consume() calls and the refill math tokens + elapsed * rate (rate=100 = 1 token / 10ms) added a token back — flipping a "burst exhausted → False" assertion non-deterministically to True. This is the CLAUDE.md flaky-timing class (never gate pass/fail on wall-clock; #1830/#1815 family), NOT the #1883 shared-global pollution class the issue hypothesized — every limiter here is test-local and reads no leaked global. Fix: a _monotonic = time.monotonic module-level seam in python/djust/rate_limit.py (production behavior identical; one indirection) routes TokenBucket.__init__ + consume() through a patchable name WITHOUT patching the global time module; a FakeClock + frozen_clock pytest fixture monkeypatches djust.rate_limit._monotonic so the five burst-exhaustion tests run on a FROZEN clock (elapsed == 0 → no refill → deterministic), and test_token_bucket_refills replaces time.sleep(0.05) with frozen_clock.advance(0.05) (deterministic, instant, still genuinely exercises the refill path). Reproduce-first verified: advancing the clock 15ms between burst checks flips the 3rd ping check False → True. Gate-off (#1468) verified: with an advancing clock the rate=100 tests go RED at a 15ms stall and the slower rate=10/rate=1 tests go RED at a 2s stall (frozen clock load-bearing for all six), and the refill advance() is load-bearing (without it the drained token stays unavailable). 3-clean-runs gate (#1174): full suite -n auto × 3 all clean (8603 passed each, the unrelated pre-existing async-timing flake #1931 deselected). Fixture applies to the burst tests in TestRateLimiter and TestGlobalRateLimit.

  • An inline <script> (or <style>) inside the dj-root is no longer silently neutered by whitespace collapse, so its page JS actually runs on mount (#1927; the live-morph twin of #1848/#1871). TemplateMixin._strip_comments_and_whitespace — the single normalizer every render path runs (HTTP GET, WS mount, SSE/runtime, streaming) to match the Rust VDOM parser's whitespace pass — preserved whitespace only for <pre>/<code>/<textarea>, but the Rust parser ALSO preserves <script>/<style> (crates/djust_vdom/src/parser.rs:475). So the re.sub(r"\s+", " ") pass collapsed every newline inside an inline <script> onto ONE line; a leading // line comment then commented out the entire body, so the script's addEventListener / init never ran — with NO console error. This is why #1871's window.djust._runInsertedScripts mount-morph re-execution could not cure the symptom: the script was already neutered at render, before any morph re-execution. The fix adds <script>/<style> to the preserved-block set (the #1646 parallel-path-drift cure: the Python normalizer now matches the Rust parser's preserve set exactly), and — CRITICAL ORDERING — extracts the raw-text <script>/<style> blocks BEFORE the HTML-comment strip so an HTML-comment-looking token inside a JS/CSS body (var s = '<!-- x -->') is not mistaken for markup and stripped. Non-script/style whitespace collapse is unchanged. Diagnosed by driving the demo /demos/browser-smoke/ page in a real browser (the inline tab-toggle script's __smokeTabsWired stayed undefined until this fix); validated end-to-end in-browser (both the HTTP-GET parse AND the #1610 WS-mount morph now run the script, tab toggle works, no console error). New cases in TestStripCommentsAndWhitespace (python/djust/tests/test_strip_whitespace.py): the exact #1927 //-comment-led-body trigger, multi-script/style preservation, the comment-inside-script ordering guard, and a "non-script whitespace still collapses" non-regression. Gate-off (#1468) verified: reverting the <script>/<style> preservation collapses the body to one line and reds the comment-not-swallowed assertion. The now-blocking browser-smoke CI job (this PR) is the end-to-end validator.

  • A batched object/permission-denied mount no longer closes the SHARED WebSocket socket, killing the sibling mounts (#1922, #291-consistency). WSConsumerTransport.finalize_mount_auth closed the socket with code 4403 UNCONDITIONALLY on the permission_denied verdict, while gating the redirect verdicts (login-required / on_mount redirect) on not mounting_in_batch (the #291/#1780 multiplexed-path rule). Inside a mount_batch the socket is SHARED across sibling mounts, so a single object-level- or permission-denied view dropped the shared socket and collaterally killed the survivor mounts (the #291 failure class; pre-existing parity with the old bespoke handle_mount which also closed unconditionally). The permission_denied close is now gated on not self.mounting_in_batch too, so all blocking mount-auth verdicts share one batch-aware close. No security loss: the denied view is NOT mounted regardless — the runtime sends the error (permission_denied) frame and clears view_instance BEFORE finalize_mount_auth runs; only the transport-level socket close is suppressed in the batch case, so the denied view simply reports in failed[] exactly as the redirect case already reports in navigate[]. The denial holds; the siblings (which the client IS authorized for) are no longer dropped. A SINGLE (non-batch) denied mount STILL closes 4403 (mounting_in_batch is False outside a batch). New cases in test_ws_auth_close_socket.py (real WebsocketCommunicator, mirroring the #291 batch harness): test_mount_batch_with_objperm_denied_view_does_not_close_shared_socket (denied view → failed[], public sibling mounts, shared socket pongs = open) and test_single_objperm_denied_mount_still_closes_socket (over-gating guard). Gate-off (#1468) verified: reinstating the unconditional permission_denied close makes the batched-denial test go RED (the ping openness probe receives websocket.close instead of pong); the redirect-verdict gate and the single-mount close are unchanged.

  • Post-mount-flip cleanup — the DEBUG event-render residuals THE FLIP scoped out are now folded onto the runtime path, and the dead _extract_* consumer copies are removed (#1908, #1921). Two post-convergence cleanups from the WS event/mount flips (#1907/#1919), both inert in PRODUCTION. (#1908) DEBUG residuals: the deleted bespoke _send_update attached three things a runtime-routed WS event (which sends via transport.send directly) dropped — (1) the per-event _debug debug-panel payload (_attach_debug_payload, DEBUG + _debug_panel_active gated) plus the top-level timing / performance fields (gated on _should_expose_timing() = DEBUG or DJUST_EXPOSE_TIMING); (2) the no_patches context_snapshot the bespoke path passed to _emit_full_html_update; and (3) the cosmetic _current_event_name / _current_event_ref consumer attrs. A new Transport.on_event_frame(view, frame, *, event_name, event_ref) hook (SSE no-op) — called by _render_and_send in-place just before every patch / html_update event frame — attaches (1) via the consumer's existing _attach_debug_payload + _should_expose_timing (verbatim bespoke gate; performance from the event_context-borrowed PerformanceTracker; timing.render from a render-duration measured per event) and stamps (3); on_render_emitted grew a context param so the no_patches branch threads get_context_data() back into the snapshot (2), re-captured only under DEBUG so PRODUCTION never double-calls it. PRODUCTION byte-identical: every attached field is DEBUG/timing-gated, so a prod-mode WS event frame is unchanged (both were also absent in prod on the bespoke path); the internal _timing_render_ms marker is always popped before send and never reaches the wire. (#1921) dead code: the LiveViewConsumer._extract_cache_config / _extract_optimistic_rules copies had ZERO callers after the mount flip deleted the handle_mount body that called them (orphan-grep confirmed across python/ + tests/); ViewRuntime owns the live copies the mount frame uses. Removed; the runtime docstrings' stale "Mirror of LiveViewConsumer._extract_*" refs are corrected. No change to RUNTIME_OWNED_VERBS / routing; SSE unaffected. New cases in TestResidualFoldObservability + TestDebugResidualOnEventFrame (python/djust/tests/test_ws_event_flip_parity_1896.py): real-WebsocketCommunicator DEBUG-vs-PRODUCTION parity (a DEBUG event frame carries _debug, timing under expose-timing; a prod frame carries NEITHER _debug/timing/performance nor the internal marker) + direct-hook unit pins for the context snapshot, the consumer-attr stamp, the panel-closed/best-effort gates, and the #1921 deletion. Gate-off (#1468) verified: gating the on_event_frame fold + the context threading off makes the 8 behavior-meaningful tests RED.

  • The SSE /event/ alias now forwards the client-sent ref so the #560 ref echo works on BOTH SSE endpoints (#1891). The /message/ endpoint forwards the raw body verbatim to runtime.dispatch_message, so a client-supplied top-level ref reached dispatch_event and was echoed on the noop / update frame (#560, ADR-022 Iter 2 Phase 2.0). The legacy /event/ alias instead REBUILT the dispatch dict as {type, event, params} and DROPPED ref — so the runtime's _dispatch_event_render (which reads ref from the top level of the data dict) saw None and echoed nothing, leaving the end-to-end ref echo exercised only via /message/. DjustSSEEventView.post now carries ref through into the dispatch frame ({type, event, params, ref}); the runtime coerces it to int / None, so no endpoint-side validation is needed. params already carried _cacheRequestId / component_id / view_id (SSE has neither component nor sticky-child routing), so ref was the only dropped field. New cases in TestSSEEventAliasRefEcho (python/djust/tests/test_sse_runtime_convergence_1887.py, real-SSE end-to-end: update + noop frames echo the ref over the /event/ alias) and TestDjustSSEEventViewPost::test_forwards_ref_to_dispatch_event (python/tests/test_sse.py, the dispatch-dict pin). Gate-off (#1468) verified: reverting the rebuild to the pre-fix {type, event, params} shape makes the two echo tests RED while the gate-off witness (which re-drops ref to confirm absence) stays green.

  • component_id-routed WebSocket events now re-render the parent and emit html_update instead of erroring (#1898, fixed by #1907 THE FLIP). The deleted bespoke _handle_event_inner component_id branch resolved + ran the LiveComponent handler but never re-rendered the parent view: html stayed None, the html_update fallback stripped None and raised TypeError, and handle_exception turned it into an error frame — so a working component event surfaced to the client as an error with no DOM update. Now that WS events route through ViewRuntime.dispatch_event, the runtime's _dispatch_component_event (the Phase-2.1 port) re-renders the parent (component VDOM is separate from the parent's), emits a parent-scoped html_update carrying the parent's updated state (e.g. values pushed up via send_parent), and echoes the event ref. The #1896 parity net's component_id test is updated errorhtml_update (the single intended behavioral change of the flip); its gate-off sibling (a bogus component_id still errors Component not found at resolution) stays green, proving the positive test genuinely resolves a real component.

  • ViewRuntime now drains all 8 flush queues like the WebSocket path, fixing flash/page-metadata/layout/a11y/i18n silently dropped on SPA navigation (#1885 / #1646, ADR-022 Iter 0). The runtime drained only 3 of WebSocket _flush_all_pending's 8 turn-end queues (push_events / navigation / deferred), so its one production user — url_change (dj-patch click / popstate SPA navigation) — silently dropped flash messages, page-metadata (title/meta) updates, set_layout swaps, accessibility announcements, and i18n commands queued during handle_params() (a live parallel-path-drift instance, #1646, INSIDE the convergence target). The runtime now has a single _flush_all_pending that drains all 8 queues in WebSocket's exact canonical order (mirrors websocket.py:888), called from both turn-end sites (event render + url_change) so a future queue addition cannot be wired on one path and not the other. New behavioral-parity nets (TestFlushQueueParity, TestWireVersionParity, TestWsOnlyBehaviorEnumeration in python/djust/tests/test_transport_behavioral_parity.py) AST-pin the WS↔runtime flush-queue set + order, the wire-version stamping (#1858), and the known WS-only mount/event behaviors so future ViewRuntime-convergence drift re-forks RED. Reproduce-first + gate-off (#1468) verified: removing the 5 added flush lines reproduces the pre-fix 3-of-8 state and the parity net detects exactly the missing {flash, page_metadata, pending_layout, accessibility, i18n}.

  • Systemic test-isolation: one autouse fixture resets djust's process-globals between tests, retiring the shared-global flaky class (#1883, #1882). Three shared-process-global test-pollution flakes in two milestones were all the SAME class — a process-global left dirty across tests in an xdist worker: #1862 (ROOT_URLCONF leak, PR #1874), #1875 (djust_hotreload channel-layer pollution, PR #1881), and #1882 (process-global wire-version drift — a stray djust_hotreload frame on the cached InMemoryChannelLayer re-renders on a later consumer and bumps its per-connection _next_version() counter, so test_time_travel_jump_recovery_version_is_current saw the jump land at version 4 instead of 3 under -n auto). Each was whack-a-moled per-test. The systemic cure is a new shared helper djust.test_isolation.reset_djust_globals() (DRY, #1646) called by an autouse _reset_djust_globals fixture in BOTH test roots (tests/conftest.py, mirroring cleanup_session_cache; and python/djust/tests/conftest.py) that resets djust's leak-prone process-globals BEFORE each test: the Channels layer manager (channel_layers.backends.clear() — the #1875/#1882 class), Django's URLconf caches (clear_url_caches() + set_urlconf(None) — the #1862 class), djust's route-map cache (_reset_route_map_cache()), and the module-level itertools.count id counters (mixins.sticky._view_id_counter, components.templatetags.djust_components._tooltip_id_counter). It is deliberately conservative (runs on every test): it resets ONLY state that genuinely leaks and is lazily re-derived, with lazy imports wrapped so a missing optional dep (Channels) never errors the fixture; it does NOT touch state_backend (already isolated by cleanup_session_cache), the keyed self-invalidating _jit_serializer_cache, the one-shot _CUSTOM_FILTERS_BRIDGED bootstrap, or per-instance StickyChildRegistry._child_views. The #1882 cure is proven deterministically + gate-off (#1468) in python/djust/tests/test_global_isolation_1883.py: a stale-layer sibling group_send reproduces the exact got 4 drift WITHOUT the reset and the clean 1 -> 2 -> 3 chain WITH it, plus per-global unit pins (neutering reset_djust_globals fails 5/8 cases). Verified with the 3-clean-runs gate (#1174): full suite -n auto × 3 (plus × 3 bonus) all clean, 8163 passed / 0 failed each run — the fixture breaks no existing test.

  • De-flaked the 17 #1721 theme-tag tests under -n auto — the systemic #1883 fixture now re-asserts the ready()-time Rust tag handlers (#1928, #1883-class). python/djust/tests/test_theme_tags_rust_engine_1721.py flaked under full -n auto: has_tag_handler("theme_panel") returned False and all 17 tests 500'd with Unsupported template tag '{% theme_panel %}'. Root cause is the same shared-process-global class as #1883: the process-global Rust tag-handler registry (crates/djust_templates/src/registry.rs) is shared across an xdist worker, and DjustThemingConfig.ready() / DjustComponentsConfig.ready() register the {% theme_X %} / {% render_slot %} handlers only ONCE per process. tests/benchmarks/test_tag_registry.py::TestRustPythonInterop clears the registry (clear_tag_handlers()) and its restore_registry fixture restores ONLY the djust.template_tags built-ins — not the app-registered theme/component handlers — so once it runs in a worker the theme handlers stay gone for every later test (also reproducible by any test that django.setup()s without djust.theming). This is the exact #1771 bug fixed only in tests/unit/test_tag_registry.py (parallel-path drift, #1646); the benchmark twin was uncovered. Systemic cure: reset_djust_globals() (python/djust/test_isolation.py) grows _reset_rust_tag_handlers(), which re-runs both ready()-time registrars BEFORE every test in both test roots — idempotent (theming guards on has_tag_handler, component overwrites) and a no-op without the Rust extension, so it is cheap. Retires the whole flaky class regardless of which polluter ran, rather than patching the one benchmark file. New cases in python/djust/tests/test_global_isolation_1883.py: test_reset_reasserts_theme_and_component_tag_handlers_1928 (clear → prove gone → reset → prove restored) + test_gate_off_clear_without_reset_loses_theme_handler_1928 (gate-off sibling proving the bare clear loses the handler, non-tautological per #1468). Reproduce-first verified: the benchmark-polluter-then-theme order failed 17/18 pre-fix and passes 18/18 post-fix; gate-off (#1468) verified (neutering _reset_rust_tag_handlers() re-reds both the repro order and the new pin). 3-clean-runs gate (#1174): full suite -n auto × 3 all clean (8604 passed / 0 failed each).

  • De-flaked test_mount_batch_with_login_view_does_not_close_shared_socket under -n auto (#1875). The #291 regression test (a login-redirecting view in a mount_batch must NOT close() the shared socket) was order-fragile under full -n auto saturation — it failed 1 of 3 full runs, passed in isolation. Two independent races, both fixed without weakening the guard: (1) the consumer joins the process-global djust_hotreload channel-layer group on connect, so a sibling test's group_send("djust_hotreload", ...) could deliver a stray frame into the test's receive_nothing window — now isolated by clearing the cached channel-layer backend so the consumer connects to a fresh, unpolluted InMemoryChannelLayer; (2) the receive_nothing(timeout=0.5) "no mid-batch close" check raced a wall-clock window (flaky under CPU saturation per the #1830/#1795 flaky-timing canon) — replaced with a deterministic pingpong openness probe (a closed socket cannot pong). Gate-off verified (#1468): removing the _mounting_in_batch close-suppression guard makes the test fail (Expected type 'websocket.send', but was 'websocket.close'). Verified with the 3-clean-runs gate (#1174): full suite -n auto × 3 all clean.

  • V004 no longer false-fires on framework-invoked lifecycle hooks (#1684). The V004 system check ("public method looks like an event handler but is missing @event_handler") flagged user overrides of hooks the framework calls directly (self.X() / getattr / hasattr) rather than through the user-event router — these must NOT carry @event_handler, but their names match the event-handler-like regex and were absent from the V004 lifecycle-skip set in checks/components.py. Canonical symptom: handle_presence_leave (bit djust-org/djust-start#5). Added the 8 framework-invoked hooks (handle_presence_join/handle_presence_leave/handle_cursor_move/handle_tick/handle_async_result/handle_component_event/handle_info/on_wizard_complete) to the skip set. The fix originally landed on the 1.1 branch (#1685) against the pre-#1822-split checks.py; it was never ported to main's split checks/ (so the false-positive was live through 1.0.8) — this lands it on main. New regression TestV004LifecycleMethods::test_v004_ignores_framework_invoked_hooks_1684 (gate-off verified, #1468).

  • djust new scaffold's settings.py template now reads DJUST_SQLITE_PATH for the SQLite NAME, falling back to BASE_DIR / "db.sqlite3". A scaffolded app's default SQLite database lived under BASE_DIR, which is read-only on a typical PaaS app rootfs (e.g. djustlive) — the first write 500'd in production. Hosts that mount a writable path now export it as DJUST_SQLITE_PATH and the scaffold picks it up automatically; local development (no env var set) is unaffected.

[1.1.0rc4] - 2026-06-30

Performance

  • Keyed per-item loop render cache — large-list render_with_diff reorders re-render only changed items, flag-gated default-OFF (#1967). Node::For in the Rust template engine previously re-rendered every loop item from the AST on every render, so a pure reorder of a 50/500-item keyed list rebuilt all N item subtrees from scratch (~9 µs/item) even though their rendered bytes are byte-identical (only positions changed). A new persistent content-hash → rendered-fragment cache (crates/djust_templates/src/loop_cache.rs, a field on RustLiveView that survives across render_with_diff calls) reuses each unchanged item's fragment, turning the loop-RENDER phase from O(n) toward O(changed): a pure reorder is all cache HITS (0 re-renders), a content-change of K items costs K misses, an append costs 1. Correctness is paramount and proven: the cache is restricted to loop bodies whose rendered output is fully determined by the loop item(s), enforced by TWO gates. (1) Position-dependent bodies are non-cacheable — any {% if %} (dj-if marker carries the loop index, #1832), {% cycle %}, nested {% for %}, {{ forloop.* }} reference, or opaque Python/component tag (a content-hash cache there would emit stale positions). (2) Bodies that read ANY outer-context variable are non-cacheable (#1967 review) — the content hash covers only the loop item(s), but a body can also read outer context ({{ prefix }}, {% with label=flag %}, {% firstof flag x.name %}, settings.X); outer context is constant within a render but NOT across renders, and the cache is persistent across renders, so a reorder after an outer-var change would serve stale fragments. A body is therefore cacheable ONLY if every top-level variable it reads is one of the loop's bound name(s) (x.name/x.price resolve under loop var x → allowed; prefix/flag → non-cacheable; tuple-unpacking for k, v allows both k and v); the dep-subset test reuses the engine's existing partial-render dependency extractor (parser::body_root_var_names). Both gates are detected once per For-node and memoized. This narrows the cacheable surface to item-only bodies — the common data-list case ({{ item.field }} only) — while non-cacheable bodies fall back to normal per-item render (correct, no win). The cached fragment is the template-render output BEFORE dj-id assignment (dj-ids are assigned downstream in the html5ever parse phase), so the keyed VDOM diff (#1678/#1682) is unaffected — output is byte-identical with the cache on vs off, verified across initial render / reorder / content-change / append / remove on plain, forloop.counter, dj-if, {% cycle %}, nested, tuple-unpacking, outer-context ({{ prefix }} / {% with %} / {% firstof %}), and dj-key templates. Default OFF (split-foundation #1122 — a hot-path change that must soak); enable via LIVEVIEW_CONFIG['loop_render_cache_enabled'] = True. When off, the For-node path is byte-identical to before. Render-phase reorder bench (crates/djust_templates/benches/loop_render_cache.rs, criterion, item-only body): N=50 ~83 µs → ~50 µs (~1.7×), N=500 ~819 µs → ~515 µs (~1.6×) — the win survives for cacheable bodies. New TestOutputIdentity / TestCacheBehavior / TestLoopRenderCacheDefaults / TestOuterContextNonCacheable classes in python/djust/tests/test_loop_render_cache_1967.py (13 end-to-end via RustLiveView.render_with_diff) + crates/djust_templates/tests/test_loop_render_cache_1967.rs (17 Rust correctness cases incl. three gate-offs (#1468): the position guard, the cross-render persistence, and the outer-context dep-subset gate are each proven load-bearing). NOTE: the end-to-end render_with_diff win is bounded by the (uncached) html5ever-parse + VDOM-diff phases (Amdahl); this lever optimizes the render half cited as the dominant cost in #1967.

  • Parsed VNode subtree cache — reorders of unchanged loop items skip html5ever-PARSE too, not just render, flag-gated default-OFF (#1970). Extends the #1967/#1969 per-item RENDER cache to ALSO cache the PARSED VNode subtree per item, keyed by the SAME content-hash, under the SAME LIVEVIEW_CONFIG['loop_render_cache_enabled'] flag + the SAME two cacheability gates. The render cache cut the loop-render phase but the html5ever-parse + VDOM-build phases are ~60% of render_with_diff (#1969's render-only end-to-end win was Amdahl-bounded to ~6-11%); this reaches that bigger half. Mechanism: LoopRenderCache (crates/djust_templates/src/loop_cache.rs) gains a second map (content-hash u64 → parsed Vec<VNode>) + a per-render item manifest. For a parse-cache HIT on a foster-parenting-SAFE item (the item's rendered root tag is NOT a table/select-family element — tr/td/th/tbody/thead/tfoot/caption/colgroup/col/option/optgroup), the Node::For arm emits a tiny <dj-pc-<nonce> h=...> placeholder (a per-render random nonce in the tag name) instead of the item's HTML, so the assembled string html5ever parses is a SHORT reduced form; render_with_diff/render_binary_diff then splice the cached parsed subtrees back into the placeholders (djust_vdom::splice_loop_placeholders) and re-assign every dj-id by a pre-order re-walk. The dj-id hazard + strategy: dj-ids are purely positional (the parser assigns next_djust_id() pre-order), so a cached subtree's baked ids are position-WRONG when reused elsewhere — naive verbatim reuse duplicates ids ([0,1,2,3,4,1,2] for a 2-of-3 identical-content list). The fix re-walks the ASSEMBLED tree from the same id-counter base the full parse would use (0 for an initial parse_html, max(old_ids)+1 for a continuing parse_html_continue after the #1550/#1552 bump), reproducing a fresh full-parse's ids byte-for-byte — so the assembled VDOM, every patch (Insert/Replace embed the new node), and last_vdom are identical to the cache-OFF path. The foster-safe gate keeps <dj-pc> out of table/select containers (where html5ever foster-parents it out, destroying structure); foster-unsafe containers, multi-root items, and any splice anomaly (placeholder cache miss / found-count mismatch / a residual dj-pc-* sentinel) fall back to a full parse — always correct, no parse win for that render. Security (sentinel forgery, the adversarial-review 🔴): the placeholder sentinel tag carries a per-render random nonce (dj-pc-<nonce>) so a loop item that renders a literal unescaped <dj-pc ...> element via |safe/mark_safe — alongside a sibling that emitted a real placeholder — can neither be mistaken for a placeholder (which would strip it + corrupt the reconstructed HTML) nor splice a different cached item's subtree into its position via a crafted h= (content-confusion); reconstruction + splice match ONLY the current render's nonce tag, and parse-cache eligibility additionally refuses any item whose rendered HTML contains the literal sentinel prefix (belt-and-braces). Without the nonce, the bug stripped the user's <dj-pc> (cache-ON) while cache-OFF preserved it — a byte-identity violation for raw-HTML loops. VNode.attrs now serialize in SORTED key order (djust_vdom::serialize_attrs_sorted) so the patch wire format is deterministic — a plain HashMap serializes in nondeterministic bucket order, which the parse-cache path (assembling a node via a different parse than the cache-OFF full parse) would otherwise surface as an ON-vs-OFF patch-JSON diff. Default OFF (rides the #1967 flag, split-foundation #1122); when off, byte-identical to before. Per-phase reorder bench (median over 60 distinct shuffles, render_with_diff): N=50 parse 0.145→0.113 ms (-21.9%) / total 0.430→0.339 ms (-21.3%); N=500 parse 1.394→1.159 ms (-16.8%) / total 4.037→3.399 ms (-15.8%) — beating #1969's render-only win by also cutting the parse phase. Correctness proven: byte-identity (html + patches + version) cache ON == OFF across plain/keyed/dj-if/cycle/nested/tuple/div/table/select/multi-root templates × initial/reorder/change/append/remove for BOTH render_with_diff and render_binary_diff (the dj-key reorder round-trip — post-diff dj-ids/dj-keys match cache-off exactly — is the load-bearing case); a parse-count probe (loop_parse_cache_hits()/loop_parse_cache_misses()) asserts a reorder of N unchanged keyed items is N parse hits / 0 re-parses and an append re-parses only the new item; gate-off (#1468) confirms neutering the dj-id re-walk fails 6 byte-identity cases AND neutering the nonce (bare-prefix sentinel) fails the 3 sentinel-collision security cases. New cases in TestParseCacheByteIdentity1970 / TestParseCountProbe1970 / TestParseCacheSentinelCollision1970 (python/djust/tests/test_loop_render_cache_1967.py), the parse_cache_1970 module in crates/djust_templates/tests/test_loop_render_cache_1967.rs, and crates/djust_vdom/tests/test_loop_parse_cache_1970.rs (literal_unnonced_dj_pc_is_not_spliced + the bare-prefix gate-off).

Changed

  • Perf (cold-start): warm the Django→Rust custom-filter bridge at startup instead of on the first mount. Request-path profiling showed the first mount after server boot paid a one-time ~20 ms cost: rust_bridge._ensure_custom_filters_bridged() lazily triggers Django to import every templatetag library (via engine.template_libraries) on first access. It's memoized after, so steady-state is unaffected — but the first request ate the latency. DjustConfig.ready() now eagerly runs the bridge (new _warm_filter_bridge() helper) so that one-time cost lands at startup, not in the first user's request. Idempotent + non-fatal; skipped under pytest (mirrors the hot-reload gate); opt out via LIVEVIEW_CONFIG['filter_bridge_warm'] = False. New cases in test_auto_hot_reload.py (TestFilterBridgeWarm-class behaviors, gate-off via the opt-out test). No steady-state behavior change.

[1.1.0rc3] - 2026-06-25

Added

  • Strict type enforcement on components/rust_handlers — the ADR-023 ratchet is COMPLETE (M4g, final module). This flips the LAST lenient holdout — the Rust template-engine component tag-registration shim (~193 inline/block render() handlers that parse untyped Rust-engine arg lists ["key=val", ...] into object-valued dicts and emit component HTML) — from the lenient mypy default to a strict island ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). This module was the sole sanctioned lenient exception (the genuinely-dynamic Rust-FFI boundary; an earlier attempt, M4b-1, found ~344 errors and documented it as intractable). It flipped clean with ZERO new # type: ignore (the only one in the file is the pre-existing _rust [import]). Two patterns did the work: (a) a typed module-level _safe() wrapper that casts Django's @keep_lazy-decorated (untyped → Any) mark_safe to str, absorbing the ~200-strong no-any-return cascade across every handler return without ignores; (b) inline cast(...) / str(...) (runtime no-ops) at each int()/float()/dict-key/attribute site of the kw.get(...) -> object cascade, plus a handful of explicit var: float/list[...]/dict[...] annotations. Render output is proven byte-identical — a deterministic-UUID parity harness rendered every handler against the pre-flip version and confirmed 382 outputs across all 193 handler classes are identical bytes (the cast/str coercions are runtime no-ops; the only str() wraps that touch lookup keys were converted to cast to guarantee key identity). mypy python/djust stays GREEN (822 files) with djust.components.rust_handlers strict; gate-off-verified (#1468) — a wrong-typed int return injected into a handler (ModalHandler.render, declared str) turns the gate RED ([return-value]), reverting restores GREEN. With M4g, no lenient exception remains in the components/ package — the global lenient default now parks only legacy non-components modules. Full suite 8604 passed / 0 failed. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the scaffolding/ + template_tags/ + theming/gallery/ subpackages — 20 modules (ADR-023 M4e, group 2). The next ratchet step flips three more subpackages from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans: the CRUD scaffolding generator (scaffolding/gen_live/gen_live_templates/generator/templates: the JSON/interactive schema-to-LiveView+admin code generator); the Rust-engine custom template-tag handlers (template_tags/{% url %}/{% static %}/{% djust_pwa %}/{% templatetag %}/{% dj_flash %}/{% djust_markdown %}/{% djust_client_config %}/{% live_render %} registered with the Rust renderer; this is the underscore template_tags/ package, distinct from the Django-engine templatetags/ package already flipped in M4d group 1); and the theme-gallery / component-storybook surface (theming/gallery/views the gallery/editor/diff + storybook DEBUG/staff-gated views, context the example-context + token-serialization builders, component_registry, urls, storybook). None of the three subpackages has a tests/ dir, so the ratchet completes each in one PR with no test sub-package to defer. Annotated with real types (params + returns — not Any cosmetics): HttpRequest/HttpResponse on the gallery views, list[dict[str, Any]] on the example builders, Callable[[Type[TagHandler]], Type[TagHandler]] on the @register decorator factory. Render output is byte-identical — the SafeString/HTML boundaries (format_html in flash, escape in markdown, Template.render in pwa, reverse in url, static in static, _client_config_html in client_config, the dynamic component .render() in component_registry) return Any under the lenient global config (Django + the cross-island live_tags._client_config_html are seen as untyped), so each is coerced with str(...) at the boundary to satisfy warn_return_any WITHOUT changing the returned (already-safe) HTML. One real type fix: scaffolding/generator.py list_display_fields annotated list[str] (was an un-annotated [] flagged var-annotated). mypy python/djust stays GREEN (822 files) with all 20 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typed int return injected into template_tags/url.UrlTagHandler.render (declared str) turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the theming/themes/ theme-definition subpackage — 66 modules (ADR-023 M4f). The next ratchet step flips the per-theme definition subpackage from the lenient mypy default to a strict island via a single glob [[tool.mypy.overrides]] module = ["djust.theming.themes.*"] (ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any) — mirroring the djust.security.* glob pattern, so a new built-in theme file added to this directory is strict-by-default with no further pyproject edit. The subpackage is 63 per-theme data modules (default / nord / dracula / catppuccin / tokyo_night / gruvbox / … — each a flat set of module-level ColorScale/ThemeTokens/ThemePreset/DesignSystem/ThemePack literals, zero functions), the dependency-free re-export hub _base, the package __init__ (pure re-exports), and the deprecated _legacy module (the Theme/THEMES dataclass API kept for backward compat). 64 of the 66 modules were already strict-clean (data + re-exports), so M4f is mostly a config-flip; the only annotation work was on _legacy._DeprecatedThemesDict's nine untyped dict overrides (__getitem__/__contains__/get/items/keys/values/__iter__/__len__) — annotated to match the dict[str, Theme] superclass signatures (the three view methods declare -> Any for the un-nameable concrete dict_items/dict_keys/dict_values return types, the established codebase pattern). No real bugs found — the theme modules are pure data and _legacy's overrides were behaviorally correct, just unannotated (logic byte-identical; deprecation-warning behavior unchanged). mypy python/djust stays GREEN (822 files) with theming/themes/* strict; gate-off-verified (#1468) two ways — a wrong-typed str return on _legacy._DeprecatedThemesDict.__len__ (declared int) turns the gate RED ([override] + [return-value]), AND an untyped def injected into a theme-DATA module (nord.py) turns it RED ([no-untyped-def]), proving the glob covers the data modules and not just _legacy; reverting either restores GREEN. Behavior is byte-identical (annotations are runtime no-ops); full suite 8604 passed / 0 failed. Note: the top-level theming/ modules are already strict (M4c part 3), but mypy's djust.theming.* glob matches only direct children, not the deeper djust.theming.themes.X submodules, so this subpackage needed its own override entry. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the loose top-level modules + backends/ + db/ — 22 modules (ADR-023 M4e, group 1). The next ratchet step flips the independent loose top-level modules and the two leaf subpackages from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the loose top-level modules (apps DjustConfig, audit_ast AST security-audit walker, audit_live runtime auditor, bug_capture harness, checks_css_proposal proposed CSS system-checks, hooks lifecycle registry, hot_view_replacement HVR engine, state_backend/template_backend back-compat re-export shims, template_filters helpers, time_travel recorder, utils shared helpers + BackendRegistry, __main__ entry point) and the two leaf subpackages: the presence backends (base, memory, redis, registry, __init__) and the PostgreSQL LISTEN/NOTIFY bridge (decorators, exceptions, notifications, __init__). Annotated with real types (params + returns — not Any cosmetics): db/decorators.notify_on_save.decorate typed type[models.Model] so _meta/label resolve, with narrow # type: ignore[attr-defined]s on the dynamic _djust_notify_channel/_djust_notify_receivers introspection attrs stashed on/deleted from the decorated model class; the signal receivers _on_save/_on_delete annotated (sender: type, instance: Any, **_kw: Any) -> None. Four genuine clean-up fixes (the kind strict-flips surface, ADR-023, all behavior-preserving): backends.registry.get_presence_backend now cast(PresenceBackend, _registry.get()) mirroring the already-strict state_backends.registry pattern (the generic registry returns Any); backends.redis.RedisPresenceBackend.count wraps the untyped zcount Any-return in int(...); db.notifications._import_psycopg gained its -> tuple[Any, Any] return; and db.notifications._dsn_from_url's URL-field loop variable was renamed (valdsn_val) to stop colliding with the earlier str-typed parse_qsl loop var so the mixed str | int | None field tuple type-checks. mypy python/djust stays GREEN (822 files); gate-off-verified (#1468) — a wrong-typed int return in backends.registry.get_presence_backend (declared PresenceBackend) turns the gate RED ([return-value]), reverting restores GREEN. Behavior is byte-identical (annotations + the four wraps/rename are runtime no-ops); full suite 8604 passed / 0 failed. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the theming/templatetags/ + theming/management/ subpackages — 8 modules (ADR-023 M4e, group 3). The continuation of the theming ratchet: M4c (part 3) made the theming MACHINERY strict but explicitly deferred the user-facing render surface (the templatetag modules — theme_components was the heaviest at ~51 errors — plus the management command). This group finishes theming by flipping those deferred leaves from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the four template-tag modules (theme_components — the ~26 themed component tags theme_button/theme_card/theme_alert/theme_input/theme_modal/theme_table/theme_nav/etc.; theme_pages — the auth/error/utility page-fragment tags theme_login_page/theme_404_page/theme_maintenance_page/etc.; theme_tags — the theme_head/theme_css/theme_switcher/theme_preset/theme_mode accessors + the shared build_theme_head_context builder; theme_form_tagstheme_form/theme_form_errors/get_css_prefix) and the djust_theme management command (tailwind-config / export-colors / list-presets / shadcn-import-export / init / create-theme / validate-theme / create-package / check-compat / marketplace-info subcommands). These tags RENDER theme components into pages, so their mark_safe/format_html return values are annotated SafeString (the HTML-safe boundary) and context/request/form params get Context/HttpRequest | None/BaseForm; output is byte-identical. The management command uses the established CommandParser/*args: Any, **options: Any shape mirroring djust_setup_css/djust_doctor. Four real type fixes to clean the islands (the kind strict-flips surface, ADR-023): theme_components.theme_progress annotates percentage: float (the min(100, (int(value)/int(max))*100) reassignment is a float; the = 0 seed inferred int[assignment]); theme_tags.theme_framework_overrides narrows the format_html result through a str local at the unstubbed-django boundary ([no-any-return]); the three _css_prefix() helpers + theme_pages._csrf_token_value wrap the untyped get_theme_config().get(...)/get_token(...) boundary in str(...); and djust_theme.handle_marketplace_info reads the required-positional mp_theme_name via subscript (not .get()) so it stays non-Optional for the themes_dir / theme_name Path division + get_component_coverage(str, ...) call ([operator]/[arg-type]). mypy python/djust stays GREEN (822 files) with all 8 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typed int return in theme_pages._css_prefix (declared str) turns the gate RED ([return-value]), reverting restores GREEN. Behavior is byte-identical (annotations + the str(...) boundary coercions are runtime no-ops) apart from the four genuine fixes above; full suite 8604 passed / 0 failed (1878 theming tests green). This completes theming/ except the optional theming/gallery subpackage, which remains for a continuation batch. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the pwa/ + optimization/ + tenants/ + observability/ subpackages — 31 modules (ADR-023 M4d, part 2). The next ratchet step after M4c (theming/ + admin_ext/) flips every non-test module of these four optional-extra subpackages from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the PWA layer (mixins PWAMixin/OfflineMixin/SyncMixin, storage offline backends + OfflineAction/SyncQueue, sync SyncManager/ConflictResolver, manifest, service_worker, utils), the optimization layer (fingerprint StateFingerprint/SectionCache/IncrementalStateSync, codegen serializer code-gen, query_optimizer select/prefetch analysis, cache SerializerCache, __init__), the multi-tenant layer (resolvers, managers TenantManager/TenantQuerySet, backends redis/memory presence, middleware ContextVar tenant binding, mixin TenantMixin/TenantScopedMixin, audit, security, models, __init__ — annotations only; tenant-isolation logic byte-identical), and the observability layer (views localhost-gated endpoints, middleware localhost gate, sql/timings/log_handler/tracebacks capture buffers, dry_run side-effect blocker, registry, urls, __init__). Annotated with real types (params + returns — not Any cosmetics), using the established mixin-collaborator pattern (# type: ignore[misc] on cooperative super().get_context_data()/dispatch() calls mirroring wizard.py/tenants; TYPE_CHECKING-only push_event/sync_queue stubs on the PWA mixins documenting the co-mixed-LiveView contract) and a narrow # type: ignore[import-untyped] on dry_run's lazy import requests (a known-stub package mypy won't silence via ignore_missing_imports). Two real bugs fixed to clean the islands (the kind strict-flips surface, ADR-023): pwa.storage.OfflineAction.id widened to Union[str, int] (callers forward an int model pk as obj_id; the SyncQueue action-id params widened to match), and pwa.mixins.delete_offline now passes the required OfflineAction(data={}) — omitting it raised TypeError at runtime on every call (a guaranteed crash in an untested path). mypy python/djust stays GREEN (822 files) with all 31 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typed str return in optimization.fingerprint.StateFingerprint.version (declared int) turns the gate RED ([return-value]), reverting restores GREEN. Behavior is byte-identical (annotations are runtime no-ops) apart from the two genuine bug fixes above; full suite 8604 passed / 0 failed. Remaining for a continuation batch: the pwa/{templatetags,management}-style leaf packages do not exist for these four subpackages, so M4d(2) completes their non-test surface. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the tutorials/, api/, template/, and state_backends/ subpackages — 20 modules (ADR-023 M4d, part 3). The next ratchet step flips four independent transport/render/persistence subpackages from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the declarative guided-tour state machine (tutorials/ — the TutorialStep dataclass + TutorialMixin async tour loop, with if TYPE_CHECKING: declarations for the sibling-mixin surface it cooperates with — push_commands/_flush_pending_push_events/wait_for_event), the opt-in HTTP-API transport (api/ — the @event_handler(expose_api=True) + @server_function dispatch views, the pluggable BaseAuth/SessionAuth contract, the view registry, the OpenAPI schema builder, and the URL wiring), the Rust template engine's Django backend (template/DjustTemplateBackend.get_template/from_string, the multi-line {# #} get_contents loaders, the DjustTemplate rendering pipeline incl. the {% extends %}/{% block %} parser + {% url %} resolver, and the serialize_valueJSONValue serializer), and the LiveView state-persistence backends (state_backends/ — the StateBackend ABC, the in-memory + Redis backends, and the registry). api/ and state_backends/ are security/correctness-relevant — annotations only, logic byte-identical: the _snapshot_assigns/_compute_changed_keys diff, the CSRF/auth/object-perm gates, the rate-limit checks, the msgpack round-trip + identity-guarded cache pop, and the zstd compression path are UNTOUCHED. Three PyO3 methods consumed by the state backends (RustLiveView.serialize_msgpack / deserialize_msgpack / get_timestamp) were added to the _rust.pyi wire-boundary stub (they existed at runtime but were missing from the stub). The only narrow coded # type: ignores are at genuine dynamic edges (the optional-JIT DjangoJSONEncoder = None / _get_model_hash = None import fallbacks in template/rendering.py; the transient None-view health-check probe entry in state_backends/memory.py); cast(...) is used at the Django/zstd/Rust unstubbed-boundary Any leaks, and assert ... is not None narrows already-guarded optionals (the next_start.end() block-parser sites, the _get_compressor() compress path gated by _compression_enabled). mypy python/djust stays GREEN (822 files) with all 20 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typed int return in state_backends/registry.get_backend ([return-value]) turns the gate RED, reverting restores GREEN. Full suite 8604 passed / 0 failed. None of the four subpackages has a tests/ subdir, so the ratchet completes each in a single PR (no test sub-package to defer). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the management/, checks/, auth/, and templatetags/ subpackages + 8 loose top-level modules — 53 modules (ADR-023 M4d, group 1). The ratchet step after the M4c subpackages (mixins/ + admin_ext/ + theming/) flips four more subpackages and the independent loose modules from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans every management command (djust_audit, djust_check, djust_doctor, djust_setup_css, djust_typecheck, djust_gen_live, djust_new, djust_schema, djust_mcp, djust_ai_context, generate_sw, cleanup_liveview_sessions) + the shared _introspect helper; the full Django system-check family (configuration/security/templates/quality/components/integrations/accessibility + the shared utils); the auth layer (the check_view_auth/run_pre_mount_auth/enforce_object_permission security core, the LoginRequiredLiveViewMixin/PermissionRequiredLiveViewMixin, the social_auth_providers context processor, the signup/login views + forms, and the djust_admin plugin + its OAuthProvidersView/SocialAccountsView LiveView pages); all five template-tag modules (live_tags — the big one with {% live_render %}/{% colocated_hook %}/{% dj_activity %} + the lazy-thunk emitter, plus djust_flash/djust_formsets/djust_pwa/djust_tutorials); and the loose modules cli, dev_server, deploy_cli, drafts, http_streaming, session_utils, push, middleware. Annotated with real types (params + returns — not Any cosmetics): SafeString at the mark_safe/format_html boundary; CheckMessage for system-check errors lists + returns; argparse.Namespace/CommandParser for the management commands; ast.* node types (ast.ClassDef/ast.Call/ast.expr/ast.Module) for the AST-based checks; AsyncIterator[bytes] for the ChunkEmitter streaming surface. The only narrow coded # type: ignores are at genuine dynamic edges: the djust.checks setattr re-export (_root.* — the patch-by-path contract from the #1822 monolith split), the djust-admin optional-dependency fallback class (no-redef/assignment), the optional _rust version export (not in the .pyi), the auth-mixin cooperative super().dispatch (provided by the combined View), and the Django model._meta access (no django-stubs). checks/ + auth/ logic is byte-identical — annotations + cast(...)/bool(...) boundary coercions are runtime no-ops; the system-check AST walkers, suppression logic, and the auth precedence (login → permission → custom hook → Django AccessMixin → object-permission) are UNTOUCHED. mypy python/djust stays GREEN (822 files); gate-off-verified (#1468) — a wrong-typed return in a flipped module (templatetags/djust_flash.dj_flashint) turns the gate RED ([no-any-return]), reverting restores GREEN. Full suite 8604 passed / 0 failed. requests (consumed by deploy_cli) joins yaml in the untyped-third-party override. Remaining for a continuation batch: the management/templatetags-adjacent long tail is already covered; the tenants/, backends/, and state_backends/ subpackages + the last few loose modules remain. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the theming/ subpackage — 39 top-level modules (ADR-023 M4c, part 3). The next ratchet step after the components/ batches (M4b) flips every top-level module of the theming system — including its small rust_handlers (unlike the components/ one, this one was already well-typed and is NOT the iceberg) — from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the registry layer (_registry_accessor singleton + registry discovery wiring), the ThemeManager + ThemeState state/session machinery, the CSS generators (theme_css_generator, pack_css_generator, component_css_generator, design_system_css, css_generator), the color machinery (palette, colors, accessibility, high_contrast, presets, design_tokens), the render paths (context_processors, template_resolver, mixins ThemeMixin, components, forms renderer), the build/adapters/tooling (build_themes, shadcn, tailwind, inspector, checks, manifest, loaders, theme_packs, compat, contracts), the apps AppConfig, views, urls, and the leaf _config/_constants/_types/_builtin_presets modules. Annotated with real types (params + returns — not Any cosmetics), using the cast(str, mark_safe(html)) boundary pattern for the theme-component renderers (django's safestring is unstubbed, so mark_safe returns Any; SafeString itself resolves to Any without django-stubs, so a str cast is the honest no-Any-leak shape). mypy python/djust stays GREEN (822 files) with all 39 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typed int return in manager.get_css_prefix ([return-value]) turns the gate RED, reverting restores GREEN. Rendering byte-identical — annotations + cast(...) + int(hue_offset) casts are runtime no-ops; the only behavior-adjacent additions are defensive if self._theme_manager is None: return guards in the four ThemeMixin event handlers (no-ops on the real post-mount path, matching the existing _setup_theme_context guard). Full suite 8604 passed / 0 failed; 1863 theming tests pass (incl. the previously-flaky test_theme_tags_rust_engine_1721, green via #1929's fixture). Remaining theming/ for a continuation batch: the theming/{templatetags,management,gallery} subpackages (the templatetag modules are the heaviest — theme_components ~51 errors — so they're a separate batch). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the mcp/ + contrib/uploads/ + uploads/ subpackages — 14 modules (ADR-023 M4d, group 4). The next ratchet step flips three independent subpackages from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans: the MCP server (mcp/server, mcp/__init__, mcp/__main__ — the AI-assistant introspection/scaffolding tool: create_server() -> "FastMCP" via a TYPE_CHECKING-guarded import so the optional mcp dep is never imported at module load, _ensure_django() -> bool, main() -> None, and the observability-tool returns); the binary-WebSocket-frame upload system (uploads/__init__ — the UploadWriter base + BufferedUploadWriter + UploadConfig + UploadManager, uploads/resumable — the resumable chunk protocol, uploads/storage — the in-memory + Redis UploadStateStore impls, uploads/views — the UploadStatusView HTTP endpoint with HttpRequest/JsonResponse annotations); and the contrib upload-writer adapters (contrib/__init__, contrib/uploads/{__init__,azure,errors,gcs,s3_events,s3_presigned} — the S3 presigned/event, GCS resumable, and Azure block-blob direct-to-storage writers). None of these has a tests/ dir, so the ratchet completes in one PR with no test sub-package to defer. Annotated with real types (params + returns — not Any cosmetics); the only narrow coded edges are: # type: ignore[override] on the legacy write_chunk(self, chunk) adapters (BufferedUploadWriter, GCSMultipartWriter, AzureBlockBlobWriter) — the dropped trailing chunk_index default is an INTENTIONAL, runtime-dispatched part of the UploadWriter contract (_writer_accepts_chunk_index introspects the signature; documented on the base method), and cast(...) narrows at the untyped boundaries (json.loads in uploads/storage, boto3.generate_presigned_url in s3_presigned, requests.Response.text + session.session_key in mcp/server/uploads/views). Two mcp/server observability-tool params dicts inferred homogeneous-then-mutated-with-the-other-type were annotated dict[str, object]. Third-party requests (consumed by mcp/server + contrib/uploads/gcs, no stubs) is marked untyped in the shared ["yaml", "requests"] override. Upload logic is byte-identical — these are security-relevant binary-frame handlers; annotations + cast(...) are runtime no-ops and NO chunk-dispatch, size-cap, or HMAC logic was altered. mypy python/djust stays GREEN (822 files); gate-off-verified (#1468) — a wrong-typed return injected into uploads/storage.delete turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed (413 upload/mcp-related tests pass). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the admin_ext/ subpackage — 13 modules (ADR-023 M4c, part 2). The next ratchet step after the components/ batches (M1 foundation → M2 public-API quartet → M3 dispatch core → M4a loose top-level → M4b-1/2/3 components/) flips the entire Django-admin integration from the lenient mypy default to a strict island ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans every non-test admin_ext/ module: the DjustAdminSite (model + plugin registration, URL generation, app-list / plugin-nav / widget collection), DjustModelAdmin (the list/detail/form/action config + queryset auto-optimization), the plugin system (AdminPlugin / AdminPage / AdminWidget / NavItem), the LiveView-based admin views (AdminIndexView, ModelListView, ModelDetailView, ModelCreateView, ModelDeleteView, LoginView, LogoutView + the admin_login_required wrapper and the _VIEW_REGISTRY plumbing), the AdminFormMixin (FK/M2M option loading, date/time field detection, readonly handling, real-time field validation), the bulk-action progress widget + @admin_action_with_progress decorator, the AdminTailwindAdapter admin CSS-framework adapter, the register/action/display decorators, the DjustAdminConfig AppConfig, the autodiscover package __init__, and the admin template-tag helpers (get_item/get_field/concat/admin_url). Excludes admin_ext/tests/, which stays on the lenient global default. Annotated with real types (params + returns — not Any cosmetics): HttpRequest/Optional[models.Model] on request/obj params, typed class-attr config (list_filter: List[Any], formfield_overrides: Dict[Any, Any], widget_id: Optional[str], …), List[URLPattern] URL builders, and the established mixin-collaborator pattern (request: Any / _model: Any / _model_admin: Any annotation-only attrs on AdminBaseMixin + AdminFormMixin documenting the co-mixed-LiveView contract, plus a # type: ignore[misc] on the cooperative super().as_view() mirroring wizard.py); decorator function-attribute stamping (wrapper.short_description = ...) carries narrow # type: ignore[attr-defined] at the genuine dynamic edge. mypy python/djust stays GREEN (822 files) with all 13 strict and the rest lenient; gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict module (adapters.register_admin_adaptersint) turns the gate RED ([return]), reverting restores GREEN. Behavior is byte-identical — annotations are runtime no-ops; the 95 admin tests (test_admin_basic/test_admin_plugins/test_admin_widgets_per_page/test_bulk_progress + admin checks) and the full suite (8604 passed / 0 failed) confirm no regression. See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the mixins/ subpackage — 21 modules (ADR-023 M4c, part 1). The eighth ratchet step (after M1 foundation, M2 public-API quartet, M3 dispatch core, M4a loose top-level, M4b-1/2/3 all of components/) flips the entire mixins/ subpackage — the LiveView mixin layer that composes the public LiveView class — from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans all 21 modules: the small leaf mixins (flash, layout, page_metadata, post_processing, async_workstart_async/defer/assign_async, waiterswait_for_event, model_binding — the dj-model mass-assignment guard, components — the child-component lifecycle), the already-clean leaves (__init__, activity, handlers, navigation, notifications, push_events, sticky, streams), the context/JIT serialization mixins (contextget_context_data/_apply_context_processors/_deep_serialize_dict, jit_jit_serialize_queryset/_jit_serialize_model/_get_template_content), the HTTP request mixin (get/aget/post + the streaming _make_streaming_response/_is_asgi_context), the Rust-bridge / change-detection mixin (rust_bridge_sync_state_to_rust/_initialize_rust_view/_normalize_db_values), and the large template rendering mixin (render/render_full_template/render_with_diff/arender_chunks + the HTML extraction/stripping helpers). Annotated with real types (params + returns — not Any cosmetics), using the established if TYPE_CHECKING: host-attribute-declaration pattern (mirroring streaming.py) for the cross-mixin/host-class surface each mixin cooperates with (get_context_data, _rust_view, template_name, etc.) — a runtime no-op resolved only at type-check time, since a mixin is never instantiated standalone. The only narrow coded # type: ignores are at genuine dynamic edges (the optional-Rust RustLiveView = None / extract_template_variables = None import fallbacks; the event_handler direct-file-import fallback shim; the dynamic component_id/_auto_id attribute sets on the Component | LiveComponent union). rust_bridge/jit change-detection is byte-identical — annotations are runtime no-ops; the _sync_state_to_rust change-detection, the _framework_attrs-class filter conventions, and all id()/value comparison logic are UNTOUCHED (no comparison or filter expression was altered). mypy python/djust stays GREEN (822 files); gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict mixin (template.get_templateint) turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed. The mixins/ ratchet completes in a single PR (no mixins/tests/ sub-package exists to defer). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the FINAL components/ modules — 68 modules (ADR-023 M4b, part 3). The seventh and last components/ ratchet step (after M1 foundation, M2 public-API quartet, M3 dispatch core, M4a loose top-level, M4b-1 core machinery, M4b-2 UI catalog) flips every remaining components/ module — except the deliberately-lenient rust_handlers — from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the component template-tag layer (templatetags/djust_components ~373 fns, _advanced ~84, _forms ~26, _charts ~23 — every Node.render(self, args/content, context) -> SafeString, do_*(parser, token) -> template.Node, and @register.simple_tag/inclusion_tag function, with inclusion_tags correctly typed -> dict[str, Any] since they return a context dict, not HTML), the per-widget mixins/data_table (the DataTableMixin — its ~21 on_table_* event handlers, handle_* override hooks, get_*/_apply_* pipeline, and the safe-arithmetic expression parser), the gallery LiveView surface (gallery/live_viewsGalleryCategoryMixin + 9 category views, with the template_name Liskov conflict resolved by a TYPE_CHECKING-only LiveView base alias; views, examples, registry, context_processors, and the component_gallery management command), the ~24 remaining components/components/* widgets with untyped private-helper params (_render_node/_squarify/_compute_diff/_eval_expression/etc.), the layout/tabs/data/pagination/ttyd/terminal leaves, and the ui/*_simple stateless widgets + ui/dropdown (the over-narrow nav-item dict widened to the honest Any contract per #1108; the optional-Rust import shims — from djust._rust import RustX / RustX = None fallbacks for built-but-unstubbed and declared-but-unbuilt Rust component classes — carry narrow # type: ignore[attr-defined]/[assignment, misc] at the genuine dynamic edge). Annotated with real types (params + returns), using the mark_safe(...) -> SafeString boundary pattern (no Any leak). rust_handlers is deliberately left LENIENT — it is a genuinely-dynamic Rust-bridge registry whose 193 handlers parse untyped Rust-engine arg lists into dict[str, object] (the kw.get() -> object cascade), so strict typing surfaces 344 errors (203 no-any-return + 91 call-overload + …) that would need >200 narrowing changes / # type: ignores with real rendering-behavior risk; the global lenient default is the correct home for it (documented exception in pyproject + this entry). mypy python/djust stays GREEN (823 files) with all 68 strict and rust_handlers lenient; gate-off-verified (#1468) — a wrong-typed return in mixins/data_table ([return-value]) and a dropped annotation in templatetags/djust_components ([no-untyped-def]) each turn the gate RED, reverting restores GREEN. Rendering byte-identical — annotations are runtime no-ops, verified by diffing the rendered HTML of all 7 chart tags, 8 representative widgets (diff_viewer/prompt_editor/heatmap/treemap/json_viewer/org_chart/pivot_table/animated_number), and 8 djust_components simple_tags against the pre-change versions (identical output), plus 143 component/data_table tests passing. Full suite 8604 passed / 0 failed. This completes the ADR-023 components/ ratchet (only the documented rust_handlers exception remains lenient within components/). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the components/ UI catalog + templatetag helpers — 185 modules (ADR-023 M4b, part 2). The sixth ratchet step (after M1 foundation, M2 public-API quartet, M3 dispatch core, M4a loose top-level batch, M4b-1 core component machinery) flips the component UI catalog and small leaf modules from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any). Spans the full components/components/ widget catalog (146 modules — alert, badge, card, spinner, kanban-adjacent leaves, charts, etc.), the ui/ stateless widgets (8 — spinner, modal, alert, progress, badge, button, card, list_group), the data//forms//layout//gallery//management//ttyd/ leaf packages, the descriptor-based components (descriptors/* — the DEP-002 Accordion/Tabs/Modal/Sheet/Dropdown/Collapsible/Carousel/Tooltip + base), and the 8 deprecated state mixins (mixins/tooltip, tabs, sheet, modal, dropdown, collapsible, carousel, accordion). Annotated with real types (params + returns — not Any cosmetics): typed *_instances class vars (Optional[Dict[str, XState]]), instance_id: str / component_id: str / is_open: bool params, get_*_ctx(...) -> Dict[str, Any] accessors, and render() -> SafeString (mirroring the markdown.py island — mark_safe(...) returns Any under django's unstubbed safestring, so SafeString is the correct str-compatible annotation that cleanly absorbs the Any without a # type: ignore). Rendering is byte-identical — annotations are runtime no-ops, verified by diffing the rendered HTML of representative UI components (spinner/alert/modal) against the pre-change versions (identical output). mypy python/djust stays GREEN (822 files) with all 185 strict and the rest lenient; gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict module (ui/spinner, descriptors/modal) turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed. Remaining components/ for the continuation batch: the Rust-bridge handler registry (rust_handlers — the ~360-error mark_safe/kw.get()-object iceberg, a separate decision), the per-widget mixins/data_table, the big templatetag modules (templatetags/djust_components/_advanced/_forms/_charts), the gallery/live_views/views/examples LiveViews, the ~24 components/components/* widgets with untyped private-helper params (_render_node/_squarify/_compute_diff/etc.), and the union-typed ui/*_simple widgets + ui/navbar_simple/modal/dropdown (over-narrow dict inference + the declared-but-unbuilt RustNavBar/Rust* fallback imports). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the components/ machinery — 15 core modules (ADR-023 M4b, part 1). The fifth ratchet step (after M1's foundation, M2's public-API quartet, M3's dispatch core, M4a's loose top-level batch) flips the core component-system machinery — NOT the UI catalog — from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any): components.__init__, components.apps, components.registry (the LiveComponent name registry), components.assigns, components.dependencies (the DependencyManager CSS/JS asset registry), components.function_component (the @component decorator + {% call %}/{% slot %} dispatch handlers), components.helpers, components.presets (the tag-preset registry), components.icons (the Heroicons SVG renderer + render_icon), components.suspense (the {% dj_suspense %} fallback renderer), components.server_event_toast (ServerEventToastMixin), components.utils (shared format_cell/interpolate_color/interpolate_color_gradient + CURRENCY_SYMBOLS), components.mixins.base (the per-component interactive mixin base — ComponentMixin + the TypedState dict subclass), components.templatetags._registry (the shared template.Library + the security-sensitive safe_url scheme-validator + _resolve/_parse_kv_args), and components.templatetags._dev_tools (the Terminal/MarkdownEditor/JsonViewer/LogViewer/FileTree dev-tool template tags). Annotated with real types (params + returns — not Any cosmetics); the only narrow coded # type: ignore[attr-defined] are at genuine dynamic edges (the @component decorator stamping _djust_* metadata onto a plain Callable; the per-invocation _slots/_children attached to a LiveComponent instance for template render). The mark_safe-returns-Any boundary is handled with typed-local narrowing (a small _safe(html: str) -> str wrapper in _dev_tools, str-typed locals elsewhere) — no Any leak. mypy python/djust stays GREEN (822 files) with all 15 strict and the rest lenient; gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict module (utils.interpolate_color) turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed. Remaining components/ for the continuation batch: the Rust-bridge handler registry (rust_handlers, ~360 errors once -> str is added — the mark_safe/kw.get()-object iceberg), the per-widget mixins.data_table, and the big templatetag modules (djust_components/_advanced/_forms/_charts), plus the UI catalog (ui/, data/, forms/, gallery/, charts UI). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on 15 loose top-level modules (ADR-023 M4a). The fourth ratchet step (after M1's foundation, M2's public-API quartet, M3's dispatch core) flips a batch of independent, low-cross-risk top-level modules from the lenient mypy default to strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any): serialization (the wire-boundary JSON/normalizer — DjangoJSONEncoder._serialize_model_safely + the finding-#19 denylist/allowlist/opt-out field gate + normalize_django_value), config, __init__, routing (the live_session URLconf walk + auth-filtered route-map emit), formsets, simple_live_view, testing (the public LiveViewTestClient + SnapshotTestMixin + LiveViewSmokeTest fuzz/smoke harness), react, rust_components, frameworks (the CSS framework adapters), js (the JS command-chain builder), wizard (WizardMixin), performance, profiler, and presence (PresenceMixin + LiveCursorMixin). Annotated with real types (params + returns — not Any cosmetics); the only # type: ignore[misc] are at genuine mixin super()-delegation edges (wizard mount/get_context_data, which the LiveView MRO supplies at runtime). mypy python/djust stays GREEN (822 files) with all 15 strict and the rest lenient; gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict module turns the gate RED, reverting restores GREEN. Full suite 8604 passed / 0 failed. The ratchet continues one batch per PR (the remaining long tail: mixins/components/theming/CLI). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the dispatch/runtime core — runtime, websocket, sse, streaming, websocket_utils (ADR-023 M3). The five modules that form the WebSocket/SSE/ViewRuntime dispatch spine (every mount + event flows through them) are now mypy strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any), the third ratchet step after M1's foundation and M2's public-API quartet. Safe to type now that the ADR-022 ViewRuntime convergence has settled (no spine code about to move). Annotated with real types (params + returns — not Any cosmetics): runtime.py (42 strict errors — ViewRuntime dispatch helpers, _build_request/_check_auth/_extract_*, the actor-mount path, _tenant_context), websocket.py (73 — LiveViewConsumer lifecycle connect/disconnect/receive, the handle_* verb handlers, the Channels event handlers server_push/db_notify/presence_event/etc., _run_async_work/_dispatch_single_event, _mount_one's 5-tuple return, the module helpers _snapshot_assigns/_compute_changed_keys/render_embedded_child_html), sse.py (17 — the DjustSSE*View get/post HTTP handlers, the owner-binding helpers, the SSE event-stream async generator), streaming.py (6 — StreamingMixin, with TYPE_CHECKING host-class attribute declarations), and websocket_utils.py (7 — the shared event-security pipeline). Only two narrow coded # type: ignore[arg-type] for genuine frame-dynamic edges (the dormant actor-event-name forward; the no-binary receive() text frame). mypy python/djust stays GREEN (822 files); gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict module turns the gate RED, reverting restores GREEN. Full suite 8604 passed / 0 failed. The ratchet continues one module per PR (M4: mixins/components/theming/long tail). See docs/adr/023-incremental-type-enforcement.md.

  • Strict type enforcement on the public-API quartet — live_view, component, decorators, forms (ADR-023 M2). The four developer-facing modules that py.typed exposes to downstream consumers are now mypy strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any), the second ratchet step after M1's foundation. Annotated: live_view.py (as_view/__init__/live_view decorator + private-state helpers) and its PEP 561 stub live_view.pyi; components/base.py (the Component + LiveComponent public bases — descriptor protocol, render waterfall, event-handler factory); decorators.py (@event_handler, @action, @server_function, @reactive, @state, @computed, @optimistic, @background + their nested wrappers/descriptors); and forms.py (FormMixin + LiveViewForm). mypy python/djust stays GREEN (822 files); the strict flip is gate-off-verified (#1468) — injecting a wrong-typed return into one of the four turns the gate RED, the same error in a lenient module stays GREEN. The ratchet continues one module per PR (M3: the dispatch/runtime core). See docs/adr/023-incremental-type-enforcement.md.

  • Enforced incremental type-checking — a mypy merge gate + strict islands + the _rust.pyi boundary (ADR-023). djust ships py.typed (PEP 561 — downstream consumers type-check against djust's hints), and pyproject.toml declared a strict [tool.mypy] config — but mypy was invoked nowhere (CI / Makefile / pre-commit), so the strict config was dead and mypy python/djust reported 8,421 errors (≈6,814 missing annotations + ~750 missing-stub imports + ~700 real type errors). This PR restructures [tool.mypy] for incremental adoption: a lenient global default (ignore_missing_imports = true + ignore_errors = true) that parks the legacy baseline so the gate is GREEN, plus per-module strict islands ([[tool.mypy.overrides]] with ignore_errors = false + disallow_untyped_defs + disallow_incomplete_defs + warn_return_any) that genuinely enforce every error class on a 22-module starter set led by the security boundary (djust.security.*) and the PyO3 wire boundary (djust._rust, typed by _rust.pyi), plus rate_limit, validation, permissions, markdown, schema, signals, async_result, test_isolation, and the well-annotated _-prefixed leaf modules (_client_ip, _log_utils, _html, _view_resolution, _deprecation, _context_provider). The gate is enforced: a non-continue-on-error mypy step in the python-tests CI job (a new MERGE GATE — #1236 governance — wired into the test-summary AND-condition; ships gating because it is green by construction, per #1534), a make typecheck target (in make check), and a scoped pre-commit hook on python/djust/**.py{,i} changes. The _rust.pyi stub's top-level names are pinned to exactly match the compiled module's runtime exports and a strict island (markdown) imports through it, so the wire/serialization boundary is type-checked, not merely declared. Empirical canary (#1459): an injected missing-annotation / wrong-typed-return in a strict island makes the gate RED, while the same error in a lenient module stays GREEN — the gate is real, not cosmetic. The ratchet is one-module-per-PR, prioritising the developer-facing public API (live_view/component/decorators/forms) since py.typed exposes it. See docs/adr/023-incremental-type-enforcement.md.

Changed

  • CI: CodeQL config excludes py/ineffectual-statement (false-positive noise from the ADR-023 TYPE_CHECKING stub idiom). The strict-mypy ratchet added if TYPE_CHECKING: forward-declaration blocks across the mixins (cooperating-attribute/method stubs with ... bodies so each strict-island mixin resolves names supplied by sibling classes at MRO time — zero runtime effect). CodeQL's py/ineffectual-statement flags every ... Ellipsis body; all 50 hits were this idiom (the genuine useless-expression class is covered by ruff). Added the rule to .github/codeql/codeql-config.yml's query-filters so it doesn't recur as the type-checking blocks grow. Also converted a cast("Any", …) string forward-ref to a direct cast(Any, …) in components/rust_handlers.py so CodeQL sees the Any import as used (#2553).

  • CI: the shared Playwright harness now waits for the demo server's actual canary route to be ready before the browser run — removes the cold-cache page.goto flake on the BLOCKING browser-smoke gate (#1943). The blocking browser-smoke gate (#1869) intermittently red-barred unrelated PRs with Page.goto: Timeout 30000ms exceeded navigating to /demos/browser-smoke/: on a cold-cache run that compiles the djust_components Rust crate from scratch, the demo uvicorn server wasn't ready within the fixed 30s page.goto deadline (it cleared on a warm-cache re-run — not a real break). The .github/actions/djust-playwright-server readiness step (shared by every playwright job) is fixed at the source: (1) its poll bound is bumped 30s → 120s (60 attempts × 2s); (2) it now polls the ACTUAL canary target route (/demos/browser-smoke/) in addition to /, using curl -fsS so a half-initialized app's 5xx does NOT count as ready — this warms the route (first-hit lazy import/compilation) DURING the bounded loop so page.goto lands on an already-warm route instead of racing its own deadline. tests/playwright/test_browser_smoke.py's page.goto also gets an explicit 60s timeout (belt-and-suspenders, up from the 30s default). The gate's SIGNAL is preserved: a genuinely-down server still fails LOUD (exit 1 + server.log dump) once the 120s bound is hit, so a real runtime break of either #1849/#1848 class still red-bars the PR.

Fixed

  • HTML preserve-block regexes now match end tags with trailing whitespace (</script >, </style\n>) — CodeQL py/bad-tag-filter #2482. _strip_comments_and_whitespace() (mixins/template.py) masks <script>/<style>/<pre>/<code>/<textarea> raw-text blocks behind placeholders before the HTML-comment-strip + whitespace-collapse passes, so their bodies aren't corrupted. The end-tag patterns used a bare </tag>, but per the HTML5 tokenizer an end tag closes on </tag followed by whitespace, /, bogus attributes, or > — so </script >, </script\n>, and even </script bar> all close a <script> in a browser. The bare pattern missed those forms, so the block was NOT preserved, letting a comment-looking token inside the JS/CSS body (var s = '<!-- x -->') get stripped and the script corrupted. All five patterns now use </tag[^>]*> (CodeQL's recommended form, matching every close variant). New TestEndTagWhitespacePreservation (whitespace + newline + bogus-attribute cases, gate-off verified) in test_strip_whitespace.py.

  • _run_async_work no longer writes against a stale view on disconnect/re-mount mid-await (#1940). LiveViewConsumer._run_async_work runs as a detached ensure_future task that captures view = self.view_instance before its first await (the background callback). If a disconnect (which nulls view_instance) or a live_redirect / re-mount (which reassigns view_instance to a NEW view) interleaved during that await window, the completed task ran its handle_async_result + _sync_state_to_rust + render_with_diff + source="async" frame against the torn-down or replaced view — a pre-existing untested race (#245/#1198 TOCTOU class). Added an identity-guard after the callback await on both the success and error paths: if the consumer's live view is no longer the captured one, the stale re-render is dropped. Cancellation can't stop the in-flight worker thread (sync_to_async runs in a thread pool), so an identity-guard — not task cancellation — is the correct cure. The normal (no-teardown) async-work path is byte-identical. New cases in TestRunAsyncWorkTeardown (python/djust/tests/test_run_async_work_teardown_1940.py), gate-off verified.

  • TutorialMixin now initializes its four internal tutorial-signal attrs in __init__, not the tutorial_total_steps setter (#1952). _tutorial_active_target, _tutorial_active_class, _tutorial_skip_signal, and _tutorial_cancel_signal were previously initialized inside the tutorial_total_steps SETTER, so a TutorialMixin view that never set tutorial_total_steps (or read those attrs before the setter ran) hit AttributeError — e.g. _cleanup_active_step() (called from start_tutorial's finally block) reads _tutorial_active_target/_class, and skip_tutorial/cancel_tutorial read the skip/cancel signals once running. The four attrs now default to None in __init__ (placed alongside the existing _tutorial_running/_tutorial_current_step/_tutorial_total_steps inits); the setter keeps its sole job of updating _tutorial_total_steps. Surfaced by ADR-023 M4d typing (PR #1951), left untouched then per #1079 typing-PR scope. New regression cases in TestSignalAttrsInitializedInInit (read the four signals without invoking the setter; pre-fix raised AttributeError).

  • ComponentMixin.update_component no longer raises AttributeError on a LiveComponent (#1947). update_component(component_id, **props) calls component.update(**props), but LiveComponent (a subclass of ContextProviderMixin, NOT Component) had no update() method at runtime, so any LiveComponent that did not define its own update() raised AttributeError on that path (the latent bug annotated with a # type: ignore[attr-defined] in ADR-023 M4c, part 1). LiveComponent now has a base update(**kwargs) that sets each prop as an instance attribute (mirroring the Python/hybrid path of Component.update) and returns self for chaining — the same component.update(**props) API the component docs already document. Subclass update() overrides still take precedence. The stale # type: ignore[attr-defined] at the call site is removed. Regression coverage in TestUpdateComponentNoUpdateOverride (tests/unit/test_component_parent_communication.py): bare-LiveComponent update through update_component, base-update() chaining returns self, subclass-override-still-wins.

  • Dev-env: detect + recover the core.bare = true shared-config corruption that breaks worktree + main checkout (#1938). A linked git worktree shares one .git/config with the main checkout; if anything flips core.bare to true there (a build/PyO3-repoint step running git config core.bare true, an IDE/GitKraken integration, or a stray manual command — the #1804/#300 pattern), git status/git push break in BOTH trees (every tracked file shows as deleted). An exhaustive audit confirmed no djust pre-push hook, test, or script writes core.bare — every in-repo git operation is read-only (git status/diff/grep/ls-files/rev-parse) or scoped to an isolated tmp dir (test_run_with_venv_python.py, test_git_commit_with_precommit.py, test_deploy_cli.py's git init fixtures), and all three were verified empirically to leave core.bare unchanged — so the corruption is external, not a framework bug. New scripts/check-shared-git-config.sh reads core.bare from the SHARED config (resolved via --git-common-dir, works from any worktree), reports a leak (exit 1), and with --fix performs the documented recovery (core.bare false); it NEVER writes core.bare true. The worktree-subagent mitigation (push --no-verify; CI is the authoritative gate) plus the detector are documented in CONTRIBUTING.md "Working in a git worktree". Tested by 5 cases in tests/test_check_shared_git_config.py (build a throwaway main+worktree, simulate the leak in the throwaway shared config, assert detect + --fix-recover + the never-writes-true invariant; gate-off self-tested per #1468).

  • Real type gaps surfaced flipping management/checks/auth/templatetags + loose modules to strict (ADR-023 M4d, group 1). None changed runtime behavior; each removes a latent contract lie. Convergence dividend (one real annotation bug, fixed): mixins/request.py's _streaming_iter was annotated AsyncIterator[str] but yields the ChunkEmitter's bytes chunks (the emitter encode("utf-8")s every chunk before queueing, and StreamingHttpResponse is fed bytes) — the strict typing of http_streaming.ChunkEmitter._aiter_impl() -> AsyncIterator[bytes] exposed the mismatch in the (already-strict M4c) mixins/request.py; corrected to AsyncIterator[bytes]. Other gaps (annotation-only, no behavior change): checks/security.py check_security's S002 @csrf_exempt scan read node.body[0].value.value (an ast.Constant.value, a str | bytes | int | … union) and called .lower() on it — guarded with isinstance(doc, str) so a non-str first-statement constant can't AttributeError (it never matched "csrf" anyway); _decorator_callable_name typed Optional[str] so the _is_permission_required_decorator comparison stops leaking Any. auth/core.py check_view_auth's login_url (a getattr(...) or getattr(...) over unstubbed Django) cast to str for the _check_django_access_mixins(login_url: str) contract; check_redis (djust_doctor) returns Optional[_CheckResult] (it returns None to skip the non-Redis path). Plus bool(...)/str(...)/cast(...) boundary narrowing at the Django-untyped surface (user.has_perms(...), apps.is_installed(...), self.style.SUCCESS(...), json.loads(...), template.render(...), click.prompt(...)) and dict[str, Any]/list[CheckMessage] var annotations where mixed-type literals were inferred too narrowly. cleanup_liveview_sessions imports the session helpers from their canonical source (djust.session_utils) instead of the live_view re-export so the strict island resolves them (equivalently exported via live_view.__all__).

  • Real type gaps surfaced flipping the theming/ subpackage to strict (ADR-023 M4c, part 3). None changed runtime behavior; each removes a latent contract lie, verified rendering byte-identical. theming/manager.py: ThemeState.pack was annotated str with a None default (a dataclass field lying about nullability — get_state() returns pack=None when no pack is configured), which also made the ThemeState(pack=pack) construction an [arg-type] error against str | None; corrected to str | None = None. theming/_registry_accessor.py: the ThemeRegistry singleton's _presets/_themes/_packs/_manifests/_discovered were assigned only through a local inst in __new__, so mypy saw 25 [attr-defined]/[has-type] errors at every access in _registry_accessor + registry — declared them as class-level annotations (dict[str, Any] / bool), the canonical singleton-attr fix (the attrs are still populated once per process in __new__). theming/theme_css_generator.py + theming/pack_css_generator.py: self.ds / self.pack were DesignSystem | None / ThemePack | None (the get_design_system/get_theme_pack return type) but __init__ raises when None, so every later self.ds.typography / self.pack.icon_style access was a union-attr error (14 in pack, 8 in theme) — narrowed by assigning the post-raise non-None value to a self.ds: DesignSystem / self.pack: ThemePack annotated attr. theming/manager.py: the two CompleteThemeCSSGenerator reassignments (in generate_critical/deferred_css_for_state) collided with the inner ThemePackCSSGenerator gen var's inferred type ([assignment] + a phantom [attr-defined] on generate_critical_css) — renamed the inner var pack_gen. theming/palette.py: s_h/a_h mixed int (from hex_to_hsl) and float (from params[hue_offset] % 360, where _MODE_PARAMS is inferred dict[str, float] because it mixes sat_scale=0.85 with integer hue offsets) — wrapped the (always-integer) hue offsets in int(...) to keep s_h/a_h int (int(180) % 360 is identical). theming/build_themes.py: build_all declared -> Dict[str, str] but artifacts["individual_themes"] is a list[str] — the return type lied about the heterogeneous shape; corrected to Dict[str, Any] (+ the manifest/artifacts locals annotated). theming/accessibility.py: a float ** float returned Any ([no-any-return]) and a bool-or chain over unstubbed-attr comparisons returned Any — wrapped in float(...)/bool(...). theming/mixins.py: the conditional-import event_handler fallback was an unguarded [no-redef] (narrow # type: ignore[no-redef]), _theme_manager was ThemeManager with a None default (corrected to ThemeManager | None), and the four event handlers gained if self._theme_manager is None: return guards so the _theme_manager.set_mode(...) accesses type-check (no-ops post-mount, matching _setup_theme_context's existing guard). Plus several mark_safe(...)/config-.get(...)/cookie-.get(...) Any-leaks narrowed at the boundary (cast(str, ...) / str(...)).

  • Real type gaps surfaced flipping the admin_ext/ subpackage to strict (ADR-023 M4c, part 2). None changed runtime behavior; each removes a latent contract lie, verified behavior-identical by the admin test suite. admin_ext/views.py: LoginView.update_username / update_password had implicit-Optional defaults (field: str = None) that PEP 484 prohibits — corrected to Optional[str]; and ModelCreateView.mount overrode ModelDetailView.mount(self, request, object_id=None, ...) with a narrower mount(self, request, **kwargs) signature (an LSP [override] violation) — restored the object_id parameter (still forced to None internally, so the create view's "always start with no object" behavior is unchanged) so the override is contract-compatible. admin_ext/options.py: several warn_return_any leaks at the Django-untyped boundary narrowed at the return site — _widget_has_permission returns bool(user.has_perms(...)), get_form pins modelform_factory(...) to a typed local, and get_field_display_name wraps the verbose_name/short_description reads in str(...). admin_ext/plugins.py: NavItem.has_permission / AdminWidget.has_permission / AdminWidget.render similarly narrowed (bool(request.user.has_perm(...)), str(render_to_string(...))). admin_ext/__init__.py autodiscover and admin_ext/apps.py DjustAdminConfig.ready gained explicit -> None returns.

  • Real type gaps surfaced flipping the mixins/ subpackage to strict (ADR-023 M4c, part 1). None changed runtime behavior; each removes a latent contract lie or surfaces a latent bug for follow-up. Latent bug (annotated, NOT fixed — out of scope #1079): mixins/components.py ComponentMixin.update_component calls component.update(**props) after isinstance(component, LiveComponent), but LiveComponent (which subclasses ContextProviderMixin, NOT Component) has no update method at runtime — confirmed via the live MRO (Component.update exists; LiveComponent.update does not). So update_component() would raise AttributeError if ever invoked with a LiveComponent. The strict flip carries a narrow # type: ignore[attr-defined] with a comment at the call site; the fix (move update onto LiveComponent, or change the routing) is left for a dedicated bugfix PR since mixins/ M4c(1) is annotation-only. Other gaps (annotation-only, no behavior change): mixins/page_metadata.py _pending_page_metadata / _drain_page_metadata were List[Dict] (incomplete generic) → List[Dict[str, str]]. mixins/model_binding.py allowed_model_fields class attr was inferred None (from = None) → annotated Optional[List[str]] (the true subclass-override contract); _dj_model_fields bare frozensetfrozenset[str]. mixins/rust_bridge.py rendered_context = {} was inferred Dict[str, dict[str, Any]] from its first (dict-valued) assignment, breaking later primitive/str assignments → annotated Dict[str, Any] (no logic change; the change-detection path is byte-identical). mixins/template.py pos = open_pos + 4 mixed the float("inf") sentinel into an int accumulator → narrowed int(open_pos) in the branch where open_pos < close_pos guarantees a real int; _current_html_size/_previous_html_size declared Optional[int] to match the getattr(..., None) first-render seed. mixins/jit.py _variable_extraction_cache was Dict[str, dict] but stores Optional[dict]Dict[str, Optional[dict]]; the if not extract_template_variables truthy-function check (a function is always truthy) → is None.

  • Real type gaps surfaced flipping the FINAL components/ modules to strict (ADR-023 M4b, part 3). None changed runtime behavior; each removes a latent contract lie, verified rendering byte-identical. components/components/button.py + components/ui/list_group_simple.py: Dict[str, any] (the builtin any function used as a type — a typo) corrected to Dict[str, Any]. components/ui/modal_simple.py: Modal._render_custom read self.show but __init__ never assigned it (the show= kwarg was passed to super().__init__ but not re-set as an instance attr like its siblings body/title/etc.) — [attr-defined] against the RustModal-instance path; added self.show = show to match the established pattern (byte-identical: in the Python-fallback render path the base already set it via its kwargs loop). components/components/prompt_editor.py: self.template reads were typed Optional[str] (the base Component.template: Optional[str] class attr) while the subclass always sets a str — narrowed via a template = self.template or "" local at the top of _render_custom. components/ui/navbar_simple.py: the nav-items param was annotated List[Dict[str, Union[str, bool, List[...]]]] which mis-typed the nested-dropdown access as non-iterable (union-attr on .get/__iter__) — widened to List[Dict[str, Any]] (the honest contract for heterogeneous dynamically-accessed dicts, #1108). Float/int local-init mismatches in chart/heatmap/pivot renderers (total = 00.0, y = ...y: float = ..., row_total/col_totals/grand_total → float) where a numeric accumulator was seeded int then +='d a float (output via :.1f/_format_val is identical). components/gallery/registry.py: cat = info.get("category", "misc") was object-typed (from the heterogeneous EXAMPLES literal) so cat.title() was attr-defined/call-overload — coerced cat = str(...) (category is always a str). Numerous list/dict locals across data_table/templatetags annotated to fix mixed-element var-annotated (e.g. pages: list mixing page ints and "...", col_items: list[list[Any]]).

  • Real type gaps surfaced flipping the components/ UI catalog to strict (ADR-023 M4b, part 2). None changed runtime behavior; each removes a latent contract lie. components/mixins/accordion.py + components/descriptors/accordion.py: AccordionState.active (and the descriptor's nested State.active) was annotated str, but in multiple=True mode it holds a list of open item ids — so actives.remove(value) / actives.append(value) / state.active = [value] were [attr-defined]/[assignment] errors against the declared str. Corrected to Union[str, List[str]] (the true runtime contract — single id when single, list when multiple), narrowing the list branch with cast(List[str], inst.active) (mixin, guarded by inst.multiple) / the existing isinstance(actives, list) (descriptor). The 8 deprecated state mixins (tooltip/tabs/sheet/modal/dropdown/collapsible/carousel/accordion) had component_id = self._resolve_component_id(component_id) reassign an Optional[str] return onto a now-str-typed param — coalesced to ... or "" (behavior-identical: _get_typed_instance("") and _get_typed_instance(None) both miss the instance dict and hit the inst is None guard). Typed all *_instances class vars (Optional[Dict[str, XState]]) and the descriptor _handle_event(self, state: "State", ...) params (the nested-State-subclass forward-ref, not the base TypedState, so state.is_visible/.active/etc. resolve).

  • Real type gaps surfaced flipping the components/ machinery to strict (ADR-023 M4b, part 1). None changed runtime behavior; each removes a latent contract lie. components/server_event_toast.py: ServerEventToastMixin.push_toast calls self.push_event(...), a method supplied by the host LiveView (via PushEventsMixin) and absent from the standalone mixin — mypy flagged [attr-defined]; declared the cooperating method under if TYPE_CHECKING: (the canonical djust mixin pattern, mirrors streaming.py), no runtime change. components/function_component.py: {% call %} dispatch set instance._slots / instance._children on a LiveComponent (per-invocation template-render attrs, distinct from the class-level slots declaration list) — narrow # type: ignore[attr-defined] with an explanatory comment; the @component decorator's _djust_* metadata stamps on a plain Callable likewise narrowed. components/presets.py: _BUTTON_PRESETS (heterogeneous str/bool values) was inferred dict[str, object], making the built-in register_preset(...) registration loop an [arg-type] error — annotated Dict[str, Dict[str, Any]]. components/mixins/base.py: TypedState.__init__ called default.fget(self) where property.fget is Optional — added the fget is not None guard (behavior-preserving for every real _make_property-built property). components/utils.py + components/icons.py + components/suspense.py + components/templatetags/_registry.py: several Any-leaks at Django boundaries (col.get(...)/value.strftime(...)/mark_safe(...)/conditional_escape(...)/render_to_string(...)) narrowed to str at the boundary so warn_return_any is satisfied without an Any escape.

  • Real type bugs surfaced flipping the loose top-level modules to strict (ADR-023 M4a). None changed runtime behavior; each removes a latent contract lie. react.py: ReactComponentRegistry._component_modules was annotated Dict[str, str] but every register() stores a nested {"module": ..., "export": ...} dict — the field annotation contradicted the (correct) return types of get_module_info() / get_all_modules(); corrected to Dict[str, Dict[str, str]]. presence.py: broadcast_to_presence(event, payload: Dict[str, Any] = None) declared a non-Optional param with a None default (the body already coalesces payload = {}) — corrected to Optional[Dict[str, Any]] = None. testing.py: assert_routed_views_allowed imported _routed_liveview_classes from djust.checks, where it is not re-exported (it lives in djust.checks.components) — corrected to import from the defining submodule (verified importable). performance.py: PerformanceTracker.root_node/current_node were inferred None-only from __init__ then reassigned TimingNode — declared Optional[TimingNode], and the _find_parent_node call now guards root_node (was guarding only current_node, but both are None/set together).

  • Real type gaps surfaced flipping the dispatch/runtime core to strict (ADR-023 M3). None changed runtime behavior; each makes the spine type-check clean and removes a latent contract lie. sse.py: SSESession._request was assigned (DjustSSEStreamView.get) and read (runtime.SSESessionTransport.build_request) but never declared in __init__ — added the Optional[Any] declaration alongside _event_request. runtime.py: _instantiate_error_frame was first-assigned None then a dict (mypy inferred None-only, so the dict assignments were errors) — declared Optional[Dict[str, Any]] in __init__; _instantiate_view called Optional[type]() ("None not callable") — added the ViewResolution.__bool__-implied view_class is None guard; the dormant actor-mount path called Optional[create_session_actor] — added the actor-availability guard. websocket.py: _recovery_html was str-typed from its first assignment but cleared to None on one-time use — annotated Optional[str]. live_view.pyi: the M2-island stub omitted the module-level _FRAMEWORK_INTERNAL_ATTRS that websocket._snapshot_assigns imports — added it (a stub-completeness gap the M3 flip surfaced because websocket now resolves the import against the strict stub). Several Any-leaks at Django/PyO3 boundaries narrowed at the boundary (bool()/str()/int() wraps on validate_host, child-render output, and the version helpers).

  • Real type bugs surfaced while building the mypy strict islands (ADR-023). security/attribute_guard.py: DANGEROUS_ATTRIBUTES was annotated Set[str] (mutable) but holds a frozenset — the annotation lied about mutability for a membership-only, never-mutated security denylist; corrected to frozenset[str], matching the immutable-denylist intent. security/log_sanitizer.py: sanitize_dict_for_log's result dict holds heterogeneous values (redacted strings, nested sanitized dicts, sanitized item lists) under an inferred dict[str, str] — annotated dict[str, Any]. security/state_snapshot.py (sign_snapshot) and permissions.py (dump_starter_document): [no-any-return] from untyped-dependency calls (TimestampSigner.sign, yaml.safe_dump) narrowed to str at the boundary. Plus annotation gaps closed in rate_limit, _context_provider, schema, permissions, and test_isolation (missing return/param annotations + var-annotated hints). None changed runtime behavior; they make the cited security/validation modules type-check clean under strict rules.

  • Real type bugs surfaced flipping the public-API quartet to strict (ADR-023 M2). decorators.event_handler: the untyped dual-call API (@event_handler bare vs @event_handler(...)) reported [arg-type] + "Self argument missing" at every bare-decorator call site (e.g. FormMixin.validate_field / submit_form) — the exact consumer-facing liability ADR-023 names; fixed with @overload so both forms type correctly for downstream consumers. live_view._is_serializable: _non_serializable was fixed to a 3-element tuple by inference, so the appended _thread.LockType (4th element) silently fell outside the declared type and the lock branch was effectively untyped — annotated tuple[type, ...]. live_view.pyi: the stream stub was missing the limit param present on StreamsMixin.stream (stub-vs-source signature drift, the #1646 class) — added. mixins/handlers.py: _handler_metadata had no base annotation, so its inferred non-optional dict conflicted with LiveView.__init__'s None init — annotated Optional[Dict[str, Dict[str, Any]]] to match the runtime contract (the is not None cache guard). decorators._ComputedProperty: custom metadata attrs (_is_computed, _computed_name, _computed_deps) were assigned but undeclared — declared as class annotations. None changed runtime behavior.

[1.1.0rc2] - 2026-06-24

Fixed

  • live_redirect to a non-LiveView path now falls back to a full-page navigation instead of stranding the page (#1934). With auto_navigate defaulting ON in v1.1, a live_redirect whose target is a plain Django view (e.g. a TemplateView) left the URL bar on the new path while the previous LiveView stayed mounted — the URL led the DOM with no swap. Two coupled client bugs in handleLiveRedirect (python/djust/static/djust/src/18-navigation.js): (1) the pushState fired BEFORE the view resolution, so the URL changed for a target that never got a DOM swap; and (2) — the load-bearing root cause found by symptom-up tracing, NOT the issue's cited "resolveViewPath returns falsy" — the resolution used resolveViewPath(), which has a container fallback that returns the CURRENT [dj-view]'s class on a route-map miss. That fallback is documented "only works for live_patch, not cross-view navigation", so for a cross-view live_redirect to a non-LiveView it returned the SOURCE view (truthy) and the client SPA-mounted the OLD view under the NEW URL — the exact reported symptom (URL /onboarding/, but the jira view mounts). The server's #1647 _resolve_view_path_from_url guard also returns None for a non-LiveView URL and keeps the stale client-supplied view, so the client must make the full-nav decision. Fix: a new STRICT resolveLiveViewPath() (route map ONLY, no container fallback) drives the cross-view decision; the pushState + URL-dependent side effects (updateAriaCurrent, scroll, before-navigate) are DEFERRED into the LiveView-resolved + WS-connected branch, so the URL never leads the DOM. A non-LiveView target (or a disconnected WS) does a full-page navigation validated through window.djust.safeNavigationTarget (mirroring the existing cross-origin branch, with the safeNavigationTarget open-redirect/javascript: guard). The popstate back-nav redirect (the #1646 twin) also switched to the strict resolver, so a back-nav to a non-LiveView reloads correctly instead of re-mounting the source view. The served minified bundle (client.min.js + .gz/.br/.map) was rebuilt to carry the fix. Reproduce-first + gate-off (#1468) verified: new cases in describe('issue #1934 …') (tests/js/navigation.test.js) — non-LiveView target: full-page nav, NO pushState, NO WS mount (strand-free), positive case: a LiveView target still SPA-mounts, and LiveView target but WS not connected: full-page nav — go RED when EITHER half of the fix is reverted (the strict-resolver call → the SPA branch fires safeNavigationTarget never called; the pushState-first order → the strand pushState assertion fails). Full JS suite green (1746 passed).

  • De-flaked TestMountAsyncAndPushDrain::test_mount_dispatches_async_work under parallel -n auto by OWNING THE COMPLETION SIGNAL instead of bounded-polling the scheduler (#1931; the async-dispatch sibling of #1930). The test mounts a view that schedules a background callback via start_async() in mount(), then asserts the callback ran (view.value == 42). The runtime dispatches that callback FIRE-AND-FORGET via asyncio.ensure_future(self._execute_async_task(...)) inside ViewRuntime._dispatch_async_work (python/djust/runtime.py:4444), and _execute_async_task ITSELF awaits a sync_to_async(callback) thread-pool round-trip before setting value=42. The original test waited for that to land via a BOUNDED poll — for _ in range(10): await asyncio.sleep(0) — a wall-clock-fragile race: under a CPU-saturated parallel loop the asyncio scheduler can fail to run the spawned task (which also competes for the thread pool) within 10 yields, so the assertion fired while value was still 0 (flaked 1/4 runs in the #1930 worktree; passed 3/3 in isolation). This is the CLAUDE.md bounded-poll-racing-a-real-scheduler class (#1830/#1815 family), NOT a code regression — the mount async-dispatch feature is correct and lands every time given enough scheduler turns. Fix (in python/djust/tests/test_transport_behavioral_parity.py, test-only — production unchanged): wrap the runtime's asyncio.ensure_future seam during dispatch_mount to capture the EXACT _execute_async_task task handle it spawns (the _flush_push_events fire-and-forget send, which uses the same primitive, is excluded by coroutine name so the gate-off stays sharp), then await asyncio.gather(*async_work_tasks) — a deterministic completion signal, no timing bound. Reproduce-first verified: shrinking the poll bound to 0–1 yields makes the OLD form fail 25/25 (proving the margin is razor-thin), while the new form passes 0/15 failures under 8-way CPU saturation. Gate-off (#1468) verified: disabling the _dispatch_async_work(None) call in dispatch_mount spawns no _execute_async_taskassert async_work_tasks fails (empty list), so the test is load-bearing on the actual async-dispatch path. 3-clean-runs gate (#1174): full suite -n auto × 3 all clean (8604 passed each). New behavior in test_mount_dispatches_async_work.

  • De-flaked the six rate-limit burst-exhaustion tests under -n auto by OWNING THE CLOCK — test_ping_flood_triggers_disconnect no longer flakes on a wall-clock token refill (#1930). TestRateLimiter + TestGlobalRateLimit (tests/unit/test_event_security.py) build test-local TokenBucket / ConnectionRateLimiter instances and assert burst exhaustion (e.g. rate=100, burst=2 → the 3rd check() must be False). TokenBucket read time.monotonic() directly, so under CPU-saturated parallel make test real wall-clock elapsed between consume() calls and the refill math tokens + elapsed * rate (rate=100 = 1 token / 10ms) added a token back — flipping a "burst exhausted → False" assertion non-deterministically to True. This is the CLAUDE.md flaky-timing class (never gate pass/fail on wall-clock; #1830/#1815 family), NOT the #1883 shared-global pollution class the issue hypothesized — every limiter here is test-local and reads no leaked global. Fix: a _monotonic = time.monotonic module-level seam in python/djust/rate_limit.py (production behavior identical; one indirection) routes TokenBucket.__init__ + consume() through a patchable name WITHOUT patching the global time module; a FakeClock + frozen_clock pytest fixture monkeypatches djust.rate_limit._monotonic so the five burst-exhaustion tests run on a FROZEN clock (elapsed == 0 → no refill → deterministic), and test_token_bucket_refills replaces time.sleep(0.05) with frozen_clock.advance(0.05) (deterministic, instant, still genuinely exercises the refill path). Reproduce-first verified: advancing the clock 15ms between burst checks flips the 3rd ping check False → True. Gate-off (#1468) verified: with an advancing clock the rate=100 tests go RED at a 15ms stall and the slower rate=10/rate=1 tests go RED at a 2s stall (frozen clock load-bearing for all six), and the refill advance() is load-bearing (without it the drained token stays unavailable). 3-clean-runs gate (#1174): full suite -n auto × 3 all clean (8603 passed each, the unrelated pre-existing async-timing flake #1931 deselected). Fixture applies to the burst tests in TestRateLimiter and TestGlobalRateLimit.

[1.1.0rc1] - 2026-06-24

Added

  • Native author guide (LVN-V initial cut; ADR-019; #1581). New docs/native-author-guide.md: when to use native vs WebView (decision matrix), variant resolution mechanics, the v1 widget vocabulary in template syntax (example), connection-time ?platform= selection, status of shipped vs pending iterations across the LVN track + companion repos, and a migration sketch for djust-mobile-toga consumers adopting native incrementally. Cross-links to ADR-019, native-widget-vocabulary.md, all 5 LVN tracking issues, and the 3 companion repos. This is the first cut — full migration guide + v1.0 vocabulary lock land at LVN-III + LVN-IV implementation completion.

  • NativeRenderer.resolve_template wires the variant resolver (LVN-II PR-4; ADR-019; #1578). Per-renderer resolve_template(base) delegates to template_resolver.resolve_variant using the instance's output_format. The NotImplementedError from render_with_diff now names the resolved template ("medicare/home.swiftui.html" or fallback "medicare/home.html") — invaluable for debugging "is the variant being picked up?" before the Rust-side widget VDOM walker lands. LVN-II is now structurally complete: vocabulary (PR-1) + scaffold (PR-2) + resolver (PR-3) + wiring (PR-4). The remaining substantive piece (Rust-side widget VDOM differ that produces real Patch streams from native templates) ships in a follow-up sequence — closes #1578 from a "structural seam" perspective. 2 new tests.

  • Native template variant resolver (LVN-II PR-3; ADR-019; #1578). New python/djust/renderers/template_resolver.py with variant_name(base, output_format) and resolve_variant(base, output_format). Convention: foo.htmlfoo.swiftui.html / foo.compose.html. Resolver falls through to the base HTML name when a variant doesn't exist anywhere on the template loader path — handshake never errors on a missing variant. LVN-II PR-4 wires this into NativeRenderer.render_with_diff. 6 new tests.

  • NativeRenderer scaffold + swiftui / compose registry entries (LVN-II PR-2; ADR-019; #1578). New python/djust/renderers/native.py introduces NativeRenderer with SwiftUIRenderer / ComposeRenderer subclasses. Scaffold conforms to the Renderer Protocol (output_format per platform) but raises NotImplementedError from render_with_diff — the actual widget-tree walker ships in LVN-II PR-3. RENDERERS registry now resolves ?platform=swiftui to SwiftUIRenderer and ?platform=compose to ComposeRenderer; this routes native handshakes to a defined error today rather than silent HTML fallback (which would mask client-side misconfigs). 9 new tests in test_native_renderer_scaffold.py. Existing handshake test in test_renderer_handshake.py updated to reflect that swiftui is now registered.

  • Native widget vocabulary frozen at v1 (LVN-II PR-1; ADR-019; #1578). New python/djust/renderers/widgets.py exposes WIDGET_TAGS (frozenset of 12 widget tags — the SwiftUI ∩ Jetpack Compose intersection), EVENT_ATTRS (dj-tap, dj-change, dj-input), STYLE_ATTRS (padding, spacing, alignment, foregroundColor, font), and is_widget_tag(tag). New docs/native-widget-vocabulary.md is the human-readable spec with SwiftUI / Compose mapping table + SemVer commitment (additions require a minor bump + coordinated native-client release; removals are a major bump). Used by NativeRenderer (LVN-II PR-2) and mirrored in djust-native-ios / djust-native-android (LVN-III #1579 / LVN-IV #1580). 8 new pinning tests.

  • LiveViewConsumer handshake selects renderer via ?platform= (LVN-I PR-3; ADR-019; #1577). Completes the LVN-I track (Protocol + ViewRuntime field + handshake wiring). New RENDERERS registry in python/djust/renderers/__init__.py maps "html"HtmlRenderer; get_renderer_factory(platform) resolves a factory by ?platform= value (returns None for missing/unknown, so a typo never breaks a session — falls through to HtmlRenderer default at dispatch). LiveViewConsumer._get_runtime parses ?platform= from scope["query_string"] (ASGI bytes), resolves via the registry, and passes renderer_factory to ViewRuntime construction. Browser today sends no ?platform= → factory is None → byte-identical render. LVN-II (#1578) will register swiftui + compose and the handshake selection lights up. New tests: python/djust/tests/test_renderer_handshake.py (5 cases). Full djust suite: 2999 pass / 3 skipped / 0 fail (+8 net new LVN-I tests across PR-1/2/3).

  • ViewRuntime.__init__ accepts a renderer_factory kwarg (LVN-I PR-2; ADR-019; #1577). Plumbs a renderer factory through the transport-agnostic runtime introduced in ADR-016. Stored on the instance for PR-3 (handshake) to set based on the connection's ?platform= query param. Default None for full back-compat — existing call sites in python/djust/websocket.py:4248 and python/djust/sse.py:110 are unchanged. The dispatch site (TemplateMixin.render_with_diff:942) still constructs HtmlRenderer(self) inline; PR-3 will route through the runtime's factory. New test file python/djust/tests/test_runtime_renderer_param.py (3 cases: default-None, factory-stored, existing-callers-unchanged).

  • djust.renderers package — pluggable Renderer Protocol + default HtmlRenderer (LVN-I PR-1; ADR-019; #1577). Foundation iteration of the LiveView Native track. Introduces a structurally narrow Renderer Protocol (@runtime_checkable; output_format: str; render_with_diff(...) -> tuple[str, Optional[str], int]) so the server-side reactive lifecycle can dispatch to non-HTML targets (SwiftUI, Compose — LVN-II onward in #1578). HtmlRenderer wraps the existing Django-template + Rust VDOM pipeline; behavior is byte-identical to the pre-refactor inline call. TemplateMixin.render_with_diff at python/djust/mixins/template.py:942 now dispatches through HtmlRenderer(self).render_with_diff(...) instead of inlining self._rust_view.render_with_diff(). What's explicitly NOT in this PR: ViewRuntime plumbing (PR-2 of LVN-I) and ?platform= handshake parsing (PR-3); NativeRenderer + widget vocabulary (LVN-II / #1578); crates/djust_vdom (wire format unchanged); static/djust/client.js (browser client byte-identical). New tests: python/djust/tests/test_renderer_protocol.py (11 cases covering package imports, Protocol shape, HtmlRenderer conformance via @runtime_checkable isinstance, delegation to _rust_view, and the dispatch gate that the mixin routes through HtmlRenderer instead of the inline call). Full djust test suite: 2991 pass / 3 skipped / 0 fail (no regression). Prior art: ADR-016 (ViewRuntime + Transport — this is the third pluggability axis on the same refactor).

  • Mount-spine parity nets + 6 real-WebsocketCommunicator flip gap-tests for the WS mount convergence (#1911, ADR-022 Iter 3 Phase 3.0). The regression net the eventual mount flip (Phase 3.3b) will ride. python/djust/tests/test_ws_mount_flip_parity_1911.py characterizes the six mount behaviors the flip must preserve, driving each against the CURRENT bespoke handle_mount over a real channels WebsocketCommunicator (each passes now + must stay green through the flip = the parity proof, #1466/#1780/#1468): actor MOUNT (a use_actors view renders an actor-backed mount frame, NOT the SSE refusal — Finding D), sticky_hold-before-mount-frame ORDERING via live_redirect_mount (Finding B), Channels group_add server-push reachability (a broadcast to the mounted view's group reaches the session), periodic tick started at mount (a source="tick" frame arrives with no client event), optimistic_rules + upload_configs on the mount frame, and live_redirect re-mount idempotency (mount A → live_redirect to B → B actually mounts, not a no-op — THE Finding-A net: the bespoke path nulls self.view_instance before re-mounting, and a naive flip that forgets to also reset runtime.view_instance would silently no-op the re-mount since dispatch_mount early-returns when view_instance is not None). Each asserts intermediate state + has a gate-off/contrast sibling. python/djust/tests/test_transport_behavioral_parity.py grows the mount-spine nets (mount-stash + dirty-baseline pins, mount-async/push-drain parity, mount-frame wire-version parity per Finding C's no-arm baseline, a two-queues-not-_flush_all_pending source pin) and extends _WS_ONLY_MARKERS with the WS-only mount behaviors (create_session_actor, state_snapshot_signed, _find_sticky_slot_ids, tick_interval, register_view) so a future "moved to runtime" of one trips RED. No WS routing change: RUNTIME_OWNED_VERBS ({"url_change", "event"}) and handle_mount/handle_mount_batch are UNTOUCHED.

Changed

  • Automatic SPA navigation (dj-navigate) is ON by default as of v1.1 (ADR-021 Stage 3). LIVEVIEW_CONFIG["auto_navigate"] now defaults to True (it was opt-in / False through 1.0.x). With no configuration, {% djust_client_config %} emits the <meta name="djust-auto-navigate"> flag and auto-emits the route map (#1733), so the client SPA-navigates plain <a href> links whose path resolves in the (auth-filtered, #1758) route map — Turbo-Drive-style, zero djust attributes. It degrades gracefully: external / non-LiveView links and the full opt-out matrix (modifier/middle-click, target/download, hash-only, data-no-navigate) full-reload exactly as before. Native dj-navigate is now djust's canonical SPA-navigation model (no manual live_session + route-map wiring needed). Opt out with LIVEVIEW_CONFIG["auto_navigate"] = False (e.g. apps wiring their own external TurboNav). Tests: test_auto_navigate_meta_emitted_by_default (new default) + test_auto_navigate_meta_absent_when_opted_out.

  • CI: the Playwright browser-smoke canary is now a HARD merge gate, and the #1848 inline-script check is now a hard assertion (#1869, Action Tracker #314). The #1849/#1848 runtime-break canary (tests/playwright/test_browser_smoke.py, which drives /demos/browser-smoke/ and guards the 1.0.7 runtime-break class — a LiveView refused at WS mount, and an inline <script> inside the dj-root whose delegated listener never registers under the #1610 mount morph) was carved out of the already-non-blocking playwright-tests leg into its OWN dedicated browser-smoke CI job (no continue-on-error) and wired into the test-summary aggregate gate's AND-condition, so a re-introduced runtime break of either class now red-bars the PR (mirrors the demo-checks blocking-job pattern, #1708/#1713). Promoted per #1534 only after the canary shipped green on the runner across multiple PRs in the non-blocking leg. The rest of the playwright suite (loading_attribute / cache_decorator / draft_mode / nav_hooks) stays in the non-blocking playwright-tests leg — the full suite can be flaky; only this stable two-class canary gates. The inline-script (#1848) branch of the canary, previously a tolerated known-xfail (warn-not-fail when the inline <script> never ran), is flipped to a HARD assertion now that PR #1871 fixed #1848 (re-execute classic <script> on the #1610 mount morph via window.djust._runInsertedScripts); a future regression of that fix now hard-fails the now-gating canary.

  • WS mounts now route through ViewRuntime.dispatch_mount — THE MOUNT FLIP, the #1646 mount convergence COMPLETE (#1919, ADR-022 Iter 3 Phase 3.3b). "mount" joins "url_change" + "event" in RUNTIME_OWNED_VERBS, so receive() routes every WS mount frame through the single dispatch_messagedispatch_mount chokepoint, and the ~870-line bespoke handle_mount body is DELETED — reduced to a THIN SHIM over dispatch_mount (mirroring the event flip #1907 and handle_url_change). Phases 3.0-3.3a had already grown dispatch_mount into a functional superset (F22 view resolver, run_pre_mount_auth pre-mount auth+tenant via _check_auth, on_mount hooks, session + signed-snapshot state restore, post-mount object-permission, handle_params, actor mount, no-arm mount wire version, the sticky_hold pre-mount frame, the auth verdict→close finalize, the 2-queue mount-time drain) via WSConsumerTransport hooks. This PR is the atomic flip with the three load-bearing findings wired: (A) idempotency — the shim, the _dispatch_runtime_owned mount arm, disconnect, and the live_redirect teardown all null runtime.view_instance BEFORE dispatch, so a reconnect / live_redirect re-mount is never silently no-op'd by dispatch_mount's if view_instance is not None early-return (the #560-class landmine); (B) ownership inverts — mount CREATES the view (runtime→consumer), so the shim reads back self.view_instance = runtime.view_instance, and the WS post-mount consumer setup the bespoke body did but the runtime did NOT (server-push / presence / db_notify group_add, the periodic tick_interval task, the use_actors flag, the real-scope _websocket_path/_websocket_query_string stamps, the _sticky_auto_reattached reset) is folded into the now-LIVE WS on_view_mounted transport hook — made async to await group_add — Finding B residual; (C) mount wire version via the next_mount_version hook (the no-arm consumer counter). The object-perm denial now closes the socket via finalize_mount_auth (Finding E — the bespoke unconditional close(4403) had no runtime equivalent). A mount_batch bug the flip surfaced is also fixed: ViewRuntime._instantiate_view fire-and-forgot its error frame via asyncio.ensure_future, leaking a FAILED view's error into the NEXT survivor's collector (flipping a survivor to failed[]); it now stashes the frame and dispatch_mount await-sends it inside the correct _mount_one window. handle_mount_batch / _mount_one stay WS-only (the collector contract is unchanged; finalize_mount_auth still gates the redirect-verdict close on not _mounting_in_batch per #291/#1780). Boundary pins updated to the post-flip reality: the RUNTIME_OWNED_VERBS contract, the Concern-4 mount-orchestration count-canary (run_pre_mount_auth / object-perm / validated_host_from_scope converged onto runtime.py), _WS_ONLY_MARKERS (group_add / channel_layer / tick_interval moved to the runtime hook), and the handle_mount source-grep pins (snapshot sign/unsign, skip-html, _ensure_tenant-before-restore, has_ids, mount-url validation, next-version) moved to dispatch_mount; the fake consumers in test_sw_advanced.py / test_sw_advanced_flow.py gained a permissive _rate_limiter so they drive the runtime path. Gate-off-verified (#1468): neutering the Finding-A null makes the live_redirect re-mount net (test_ws_mount_flip_parity_1911.py::TestLiveRedirectRemountIdempotency) RED; neutering the on_view_mounted fold makes the group_add-reachability + tick-at-mount nets RED. Full CI-way suite (tests/ python/tests/ python/djust/tests/ -n auto): 8577 passed, 0 failed.

  • The 5 transport mount-hooks (#1916) are now WIRED into ViewRuntime.dispatch_mount — it is a functional SUPERSET of the WS handle_mount, and the hooks go LIVE for the SSE/runtime mount path (#1917, ADR-022 Iter 3 Phase 3.3a). The last build-up before the Phase 3.3b atomic flip. Routing stays bespoke — RUNTIME_OWNED_VERBS is UNCHANGED ({"url_change", "event"}), handle_mount / handle_mount_batch are UNTOUCHED (websocket.py has no diff) — but the dormant hooks are now called by dispatch_mount at their WS-faithful positions (read off handle_mount): (1) on_view_instantiated(view) right after instantiation (WS stamps _ws_consumer / _push_events_flush_callback / observability register_view / validated host; SSE no-op) (Finding B). (2) uses_actors_for_mount / dispatch_actor_mount (Finding D) — the hard actor REFUSAL is replaced: a WS use_actors view now RENDERS through the actor system at the render step (verbatim handle_mount ordering — after auth + mount() + handle_params, html sent without strip/extract, websocket.py:2691-2706); SSE keeps refusing (uses_actors_for_mount → False, so the structured use_actors is not supported over SSE envelope is now reached only when the transport does NOT support actor mounts). (3) next_mount_version(html, rust_version) (Finding C) — the mount-frame version routes through the NO-ARM hook (WS consumer._next_version() — establishes the baseline, does NOT arm request_html recovery so _recovery_html stays None; SSE returns the raw Rust render_with_diff() version, IMPLEMENTED here — the 3.2 SSE placeholder raised). The signature is widened to (html, rust_version=1) mirroring next_client_version so the runtime hands every transport the same inputs; the default keeps the 3.2 single-arg callers working. Crucially mount does NOT route through the ARMING next_client_version the event path uses. (4) on_mount_render_ready(view, html) (Finding B residual) runs after render, before the mount frame (WS sticky preservation + the sticky_hold frame emitted BEFORE the mount frame; SSE returns html unchanged). (5) finalize_mount_auth(view, verdict) (Finding E) on the three auth-block verdicts (_check_auth permission_denied + redirect; dispatch_mount run_on_mount_hooks redirect) — the runtime already sent the verdict frame + cleared view_instance, so the hook adds ONLY the transport-level close(4403) (WS unconditional for permission-denial, gated on not _mounting_in_batch for the redirect verdicts per #291/#1780; SSE no-op); it does NOT re-send the frame. Every hook is getattr-guarded so duck-typed test fakes (and the default-bearing Protocol) keep working. dispatch_mount is now a clean superset (Findings A/B prep) — the idempotency guard + view_instance ownership are untouched; the residual delta for the 3.3b flip is the routing flip + the A/B shim (the runtime.view_instance reset + read-back) only. New cases in TestRuntimeBasicMountParity, TestRuntimeActorMountParity, TestRuntimeNoArmVersionWiring, TestRuntimeAuthBlockFinalize, TestRuntimeStateRestoreParity (python/djust/tests/test_runtime_mount_parity_1917.py) — THE key 3.3a gate: drives dispatch_mount over a REAL WSConsumerTransport (direct-call shim, NOT via RUNTIME_OWNED_VERBS) and proves WS-equivalent mount for basic mount, ACTOR mount (renders not refuses), no-arm version, auth-block #291-not-in-batch, and state restore (Phase 3.1), plus two routing-untouched pins; gate-off-verified (#1468) — the actor branch off → the view is refused again, the next_mount_version wiring off → the wrong version is stamped. The Phase-3.2 DORMANT pins in python/djust/tests/test_transport_mount_hooks_1915.py are INVERTED to load-bearing WIRED pins (each hook is now referenced in dispatch_mount / the auth helper; SSE next_mount_version returns rust_version).

  • The 5 transport mount-hooks the WS-mount flip needs are now DEFINED — DORMANT scaffolding, not yet wired into dispatch_mount (#1915, ADR-022 Iter 3 Phase 3.2). Internal scaffolding PR — zero live behavior change. Mirrors how Phase 2.3a defined the event hooks (event_context / on_event_recorded / dispatch_actor_event) DORMANT before the event flip wired + routed them. The 5 hooks land on the Transport protocol (behavior-preserving no-op / refuse defaults), WSConsumerTransport (the real WS impl, each encapsulating the verbatim bespoke handle_mount logic for its cited site), and SSESessionTransport (no-op / raw / refuse), addressing ADR-022 Iter 3 Findings B/C/D/E: (1) on_view_instantiated(view) — WS stamps view._ws_consumer + wires _push_events_flush_callback (websocket.py:2128/2134-2135), registers the view in the observability registry (2161-2167), and stashes the validated _websocket_host/_websocket_secure (2243-2270) (Finding B); SSE: no-op. (2) uses_actors_for_mount(view) + dispatch_actor_mount(view, data) — WS: use_actors and create_session_actor is not None (websocket.py:2213) → create_session_actor + actor_handle.mount(){html, version} (2213-2217/2665-2706), verbatim (Finding D); SSE: False / raise (the dispatch_mount refusal stays). (3) next_mount_version(html) — WS returns consumer._next_version(), the NO-ARM counter handle_mount uses (websocket.py:2746); crucially it does NOT call _next_version_armed / _arm_recovery (a mount ESTABLISHES the client VDOM baseline and has no prior frame to recover to — distinct from next_client_version, which arms for render-SEND frames), so _recovery_html stays None after a mount (Finding C / #1817); SSE: raw Rust version (placeholder, raises until 3.3a wires it). (4) on_mount_render_ready(view, html) — WS: sticky preservation (_find_sticky_slot_ids survivor scan + _register_child re-registration) + the sticky_hold frame emitted BEFORE the mount frame (websocket.py:2080-2082/2836-2903), returning html unchanged; SSE: returns html unchanged (Finding B residual). (5) finalize_mount_auth(view, verdict) — WS: the transport-level socket close(4403) the bespoke auth-finalization performs (websocket.py:2337-2401), GATED on not consumer._mounting_in_batch for the redirect verdicts so a batched login-required view does NOT drop the shared socket's sibling mounts (#291/#1780), unconditional for a permission-denial; SSE: no socket to drop → no-op (the runtime-sent error/navigate frame is the SSE finalization). DORMANT: dispatch_mount does NOT call any of these yet (Phase 3.3a wires them in) and the WS bespoke handle_mount / handle_mount_batch keep doing all of this inline (untouched until the Phase 3.3b flip); RUNTIME_OWNED_VERBS / WS routing are UNTOUCHED; websocket.py has no production diff. New cases in python/djust/tests/test_transport_mount_hooks_1915.py (Test... MockTransport unit tests per hook + real-WebsocketCommunicator tests exercising the WS impls in isolation against a genuinely-mounted consumer — uses_actors_for_mount True for a use_actors view, next_mount_version returns the consumer counter WITHOUT arming recovery, finalize_mount_auth does NOT close when _mounting_in_batch=True) + DORMANT pins (dispatch_mount doesn't reference the hooks, still stamps the raw Rust version + still refuses actor mounts; handle_mount still does the work inline). All gate-off-verified (#1468): arming recovery in next_mount_version reds the 3 no-arm tests, removing the not _mounting_in_batch gate reds the in-batch tests, no-op'ing on_view_instantiated reds the stamp test. The anti-drift _WS_ONLY_MARKERS pin (test_transport_behavioral_parity.py) drops create_session_actor / _find_sticky_slot_ids / register_view (no longer WS-only — the dormant WS hooks now reference them in runtime.py), mirroring the Phase-3.1 state_snapshot_signed move.

  • ViewRuntime.dispatch_mount grew the transport-agnostic mount STATE-RESTORE + on_mount hooks WebSocket handle_mount has, going LIVE for SSE mount (#1913, ADR-022 Iter 3 Phase 3.1). Second PR of the WS mount convergence (after Phase 3.0's cheap grows, #1911). Three ports, each gated on enable_state_snapshot (#1552) so default views are unaffected: (1) run_on_mount_hooks (websocket.py:2383-2401) runs the registered on_mount hooks after the pre-mount auth sequence + before mount(); a hook that returns a redirect URL emits a navigate frame, clears the unmounted view, and aborts — transport-agnostically (no socket close(); that belongs to the Phase 3.2/3.3a finalize_mount_auth hook, matching the runtime's existing auth-redirect handling in _check_auth). (2) Session-saved-state restore (websocket.py:2424-2474) reattaches the public + private state + per-process side-effect registrations (_restore_upload_configs / _restore_presence / _restore_listen_channels, hasattr-guarded) + component state the per-event session-save (#1466) wrote, on a plain reconnect-mount — in lieu of mount(). (3) The has_prerenderedskip_html_for_resume resume optimization Phase 3.0 wired (but left dormant) now ACTIVATES: a restore (session or signed-snapshot) sets the new _mounted_from_restore framework flag, so a resuming client that already holds the DOM skips the redundant mount-HTML swap (the version still flows so patches stay in sync). _mounted_from_restore is initialized in LiveView.__init__ BEFORE the _framework_attrs snapshot (#1393) so it is reset on reconnect and never persisted. Blast radius: SSE mount (which uses dispatch_mount) + the runtime; websocket.py has no diff, RUNTIME_OWNED_VERBS / handle_mount / handle_mount_batch are unchanged. The anti-drift _WS_ONLY_MARKERS pin drops state_snapshot_signed (no longer WS-only — now on the runtime too) and the live_view.py setattr-whitelist line numbers shift +11. New cases in python/djust/tests/test_runtime_mount_state_restore_1913.py (TestRuntimeSessionRestore, TestRuntimeOnMountHooks): an opt-in view's session-saved state restores on a runtime reconnect-mount while a default view ignores it (#1552 gate-off, RED when the gate is dropped); an on_mount redirect emits a navigate frame + aborts (RED when the redirect handling is gated off).

  • ViewRuntime.dispatch_mount grew the transport-agnostic mount behaviors WebSocket handle_mount has, going LIVE for SSE mount (#1911, ADR-022 Iter 3 Phase 3.0). First PR of the WS mount convergence — grows the runtime mount path toward a functional superset of handle_mount over zero-WS-routing-risk PRs (the eventual flip is Phase 3.3b). Five grows, each ported from its WS site, gate-off-verified (#1468): (1) the _djust_mount_request / _djust_mount_kwargs stash (#1895, websocket.py:2596, placed after mount() + object-perm, before handle_params) — the runtime's OWN per-event session-save fallback (runtime.py:2030/2109) already READS this attr to discover the save session + liveview_{path} namespace, so the stash makes that fallback live on the converged path instead of silently degrading to the scope session; (2) _snapshot_user_private_attrs + _capture_dirty_baseline post-mount (websocket.py:2598-2603); (3) has_prerenderedskip_html_for_resume machinery (websocket.py:2804-2816), dormant until Phase 3.1 wires session-restore (the _mounted_from_restore flag defaults False, so HTML is always sent today); (4) optimistic_rules (DEP-002) + upload_configs on the mount frame (websocket.py:2823-2834, via a new runtime _extract_optimistic_rules mirror); (5) the mount-time _flush_push_events() + _dispatch_async_work(None) drain (websocket.py:2916, #1280/#1283) — ONLY those two queues, NOT the 8-queue _flush_all_pending the turn-end event path uses (mount establishes a baseline, it does not run a full event turn-end flush), with the #1391 source-grep pin MOVED to the runtime location in test_handle_mount_drains_queues.py. Blast radius: SSE mount (which uses dispatch_mount) + the runtime; websocket.py has no diff and RUNTIME_OWNED_VERBS is unchanged. Every grow has a gate-off witness in test_transport_behavioral_parity.py (7/7 verified RED). New cases in TestMountStashAndBaselines, TestMountAsyncAndPushDrain, TestMountFrameOptimisticAndUpload, TestMountFrameWireVersion.

  • THE FLIP: every WebSocket event now routes through ViewRuntime.dispatch_event — the bespoke _handle_event_inner is deleted (#1907, ADR-022 Iter 2 Phase 2.3b). The atomic moment of the event-path convergence (the #1646 cure: one event path, not two). "event" is added to RUNTIME_OWNED_VERBS (now {"url_change", "event"}), so receive() routes every WS event through the single ViewRuntime.dispatch_message chokepoint; handle_event becomes a thin shim over runtime.dispatch_event (mirroring handle_url_change); and the ~1170-line bespoke _handle_event_inner — the WS-only twin the runtime grew to a functional superset in Phase 2.3a (#1900/#1902/#1904/#1906) — is removed. The residual observability the bespoke handler owned is folded onto two new Transport hooks (SSE no-op): on_render_emitted carries the production-visible DJE-053 warning (#1079 — it MUST survive, and does) plus the _emit_full_html_update signal on the no-patch render branch, and on_handler_timing carries the record_handler_timing percentile telemetry; cache_request_id was already threaded through the runtime render path. The flip surfaced + fixed three parallel-path-drift regressions now that the runtime event path IS the WS event path: (1) ViewRuntime._flush_navigation is now await-ed (was fire-and-forget) and (2) the skip-render branch now calls _flush_all_pending, so a live_redirect() / navigation command queued by a state-unchanging handler still emits its navigation frame within the event turn (WS parity); and (3) the runtime's _dispatch_event_render now records a time-travel snapshot with error="permission_denied" / "validation_failed" on the security-rejected + validation-rejected early-return paths (record_event_start moved BEFORE the security check) — the bespoke _handle_event_inner recorded these for the debug panel, and the first flip pass dropped them for non-actor views (caught by tests/integration/test_time_travel_flow.py::test_permission_denied_view_handler_records_with_error). Boundary pins updated (RUNTIME_OWNED_VERBS contract, the event routing pin, the _handle_event_inner-deleted assertion) and the WS-source pins (1465 save-block, 1785 recovery-arming, 1788 wire-version count, 1802 sticky-child) redirected to the runtime where the behavior now lives. New TestResidualFoldObservability (DJE-053 + record_handler_timing survival, with reason/version gate-off siblings) and a WebsocketCommunicator regression for start_async / @background streaming its source="async" result over the runtime async path. Gate-off (#1468): removing "event" from RUNTIME_OWNED_VERBS makes all 11 test_ws_event_flip_parity_1896 behaviors fail with Unknown message type: event (the bespoke elif is gone) — proving the set membership is the only switch. The DEBUG-only debug-panel payload + cosmetic consumer attrs are deferred to #1908 (inert in production). Full suite green the way CI runs it (tests/ + python/tests/ = 4732 passed; python/djust/tests/ = 3750 passed; 0 failed, 21 skipped); the entire WS event regression net (reconnect-state #1465, sticky-child #1802/#1813, reauth #1777, send-version #1788, recovery-staleness #1817, url-change wire-version #1858, transport-hardening F21/F17, ratelimit-per-caller F27/F28) stays green.

  • ViewRuntime async-result frames now carry source="async", reconciling them with the WebSocket _run_async_work frames; and the dead use_binary framing path is confirmed + pinned (#1905, ADR-022 Iter 2 Phase 2.3a). Two folds finishing the 2.3a parity before the 2.3b WS-event flip. (1) async source="async" reconcileViewRuntime._render_async_result (the start_async / @background completion render shared by the success + error paths) emitted patch / html_update frames with NO source tag, while the WS _run_async_work tags all four of its frames source="async" (websocket.py:1166/1186/1223/1238). The client uses source to distinguish an out-of-band background-completion update from the in-turn source="event" response, so the runtime frames were the lone untagged twin — a #1646 parallel-path drift INSIDE the convergence target. Both runtime async-result branches now stamp source="async". LIVE for SSE + url_change async work (both use the runtime async dispatcher today); WS picks it up post-flip (Phase 2.3b). (2) binary-framing confirmconsumer.use_binary is dead: initialized to False at websocket.py:580 ('MessagePack support TODO') and never set True anywhere in the package; the only honoring site is _send_update's binary branch (websocket.py:1391), which WSConsumerTransport.send does NOT traverse (it calls consumer.send_json, always JSON). DESCOPED (no new binary path invented) + PINNED so a future enable is a deliberate, tested change: a guard test asserts WSConsumerTransport.send emits JSON via send_json (matching live WS), plus a source-grep pin that no production module assigns use_binary = True. No change to RUNTIME_OWNED_VERBS / WS routing; WS _handle_event_inner's async/binary paths stay on the bespoke handler until 2.3b; websocket.py has no diff. New cases in TestAsyncSourceReconcile / TestBinaryFramingConfirm (python/djust/tests/test_runtime_reauth_async_1905.py): real-SSE end-to-end (a start_async completion frame carries source="async") + unit (both branches tagged) + the JSON-emit + source-grep pins, with a gate-off witness (#1468) — removing the source="async" tag makes the SSE end-to-end + unit assertions RED. test_async_integration + test_sse_runtime_convergence_1887 stay green.

  • ViewRuntime gained the transport-agnostic {% dj_activity %} deferral WebSocket has — a defer-when-hidden gate + a lock-free deferred re-dispatcher — and it goes LIVE for SSE events (a parity improvement) (#1903, ADR-022 Iter 2 Phase 2.3a). The runtime event path lacked dj_activity deferral entirely: an event targeting a HIDDEN (non-eager) {% dj_activity %} region should be queued + acked with a no-op (no render) and replayed when the panel next shows, exactly as the bespoke WS _handle_event_inner does (websocket.py:3254-3273 gate + 4290-4294 flush). Two parts: (1) GateViewRuntime._dispatch_event_render (after embedded-child routing, before security validation) replicates the WS gate VERBATIM, reusing the SAME transport-agnostic ActivityMixin view methods (is_activity_visible / _is_activity_eager / _queue_deferred_activity_event); a hidden-region event is queued and answered with the runtime's self-describing noop (type/source/event_name/ref) and no render. (2) Flush + lock-free re-dispatcher (option (a)) — after a render that may flip visibility (BOTH the skip-render and render arms, mirroring the WS post-turn flush), ViewRuntime._flush_deferred_activity_events() hands the runtime ITSELF to the consumer-blind ActivityMixin._flush_deferred_activity_events as the _dispatch_single_event provider, so mixins/activity.py is UNCHANGED (the flush already accepts any object exposing that method). The new ViewRuntime._dispatch_single_event(target_view, event_name, params, event_ref=None) re-runs validate → handler → render for one queued event WITHOUT acquiring a lock and WITHOUT re-entering event_context — it already runs inside the borrowed context (which on WS holds the consumer _render_lock; re-acquiring the non-reentrant asyncio.Lock would deadlock, the websocket.py:1467 contract). A denied queued event is re-validated and dropped (WS flush per-event parity). Live behavior: this goes LIVE for SSE events — they route through dispatch_event since Iter 1 (#1887), so SSE events now respect dj_activity deferral (the parity improvement); a no-op for SSE views with no activity region (zero-cost when unused). WS events are UNAFFECTED — the bespoke _handle_event_inner gate/flush stays until Phase 2.3b; RUNTIME_OWNED_VERBS / WS routing are UNTOUCHED; websocket.py has no diff. New suite python/djust/tests/test_runtime_dj_activity_1903.py — direct-runtime (MockTransport) + real-SSE end-to-end, each reproduce-first + gate-off (#1468): hidden-activity event → queued + noop (no render); flip-visible → the queued event drains in the same round-trip (2nd frame); no-activity view → renders normally; the re-dispatcher runs inside the borrowed context with no re-entry (no-deadlock proof, asserted via a re-entry-recording mock context); a denied queued event is re-validated + dropped; plus structural pins (gate lives in _dispatch_event_render; re-dispatcher body is lock-free; the flush passes the runtime as the dispatcher). Gate-off verified: disabling the gate makes the hidden-deferral + flip-drain tests RED; disabling the flush makes the flip-drain tests RED. The existing WS dj_activity behavior (tests/unit/test_activity.py), the #1896 parity net (bespoke path, unchanged), and test_sse_runtime_convergence_1887 stay green.

  • ViewRuntime gained an actor-event transport hook (transport.uses_actors() + transport.dispatch_actor_event()) so a use_actors view's events route through the per-session Rust actor on the runtime path too — DORMANT until the Phase 2.3b WS-event flip (#1901, ADR-022 Iter 2 Phase 2.3a). The load-bearing fold the WS-event flip sits on. ViewRuntime.dispatch_event had NO actor branch, while the use_actors guard lived ONLY in dispatch_mount (which refuses SSE outright). A WS view mounts in actor mode (use_actors=True + a created actor_handle); once Phase 2.3b routes WS events through the runtime, such a view's events would have hit dispatch_event with no actor branch and silently run the handler IN-PROCESS via the normal render path, desyncing the actor's server-side diff baseline. Two new Transport hooks close the gap: (1) uses_actors(view)WSConsumerTransport returns consumer.use_actors and consumer.actor_handle is not None (the exact precondition of the bespoke WS actor block, websocket.py:3282), SSESessionTransport returns False (SSE has no bidirectional actor channel and dispatch_mount refuses use_actors mounts, runtime.py:602); (2) dispatch_actor_event(view, event_name, params, *, event_ref, cache_request_id)WSConsumerTransport runs the bespoke WS actor block (websocket.py:3282-3379) VERBATIM against the consumer (time-travel record/push in a finally, the shared _validate_event_security + validate_handler_params checks, actor_handle.event(), patch/HTML framing stamped with the consumer-owned wire version consumer._next_version() — the actor's internal result['version'] is IGNORED for the wire, #1788 — error handling, and the v0.7.0 deferred-activity flush), SSESessionTransport raises NotImplementedError (never called — uses_actors is False). Wired into _dispatch_event_inner BEFORE event_context (the actor block holds no render lock, matching WS), gated on uses_actors(view) AND the event NOT being routed to a sticky child — the WS not is_embedded_child_target mutual exclusion (websocket.py:3280-3282); per #1467 a component_id event does NOT reassign the target view and the WS actor block has no component handling, so a component_id event on a use_actors view goes through the actor (parity), and only a view_id resolving to a DIFFERENT child excludes it (_event_routes_to_sticky_child peeks at view_id WITHOUT consuming it, so the non-actor sticky-child routing still pops it). Zero live-behavior change: uses_actors is False for both live transports today (WS events still run on the bespoke _handle_event_inner; SSE refuses actor mounts), so no live event turn reaches the hook until 2.3b. WS routing (RUNTIME_OWNED_VERBS) + the WS _handle_event_inner actor block are UNTOUCHED (they stay until 2.3b); websocket.py has no diff. New direct-runtime suite python/djust/tests/test_transport_actor_event_1901.py (12 cases) builds a WSConsumerTransport over a fake consumer with use_actors=True + a fake actor_handle and asserts dispatch_event routes to dispatch_actor_event (the actor's .event() is called + the framed result is sent via _send_update with the consumer-owned wire version, NOT the in-process handler), uses_actors False for SSE + a WS consumer without actor_handle, a view_id-routed event skips the actor while a view_id-equals-top event still routes to it, and the SSE dispatch_actor_event raises; gate-off verified (#1468) — forcing uses_actors to always return False makes the actor-routing cases go RED (the event falls to the in-process render path). The existing #1896 actor-parity test (test_ws_event_flip_parity_1896.py, the bespoke WS path) + the #1899 event_context suite stay green.

  • ViewRuntime now BORROWS the consumer's render-lock + origin-channel + observability scope for each event via a new transport.event_context() hook, and the dead runtime-local _render_lock is deleted (#1899, ADR-022 Iter 2 Phase 2.3a). Foundational fold the dj_activity re-dispatcher + the 2.3b WS-event flip sit on. Two load-bearing flip-scope findings drove this: (1) ViewRuntime._render_lock was DEAD CODE — declared in __init__, never acquired anywhere — and is removed; the runtime CANNOT own the render lock, because render serialization is consumer-owned (LiveViewConsumer._render_lock, websocket.py:619) and SHARED with the WS-only _run_tick / server_push / db_notify render loops, so a runtime-local lock would be a different object and could not serialize against ticks (the #560 version-interleave bug). (2) So a new async-CM transport.event_context(view) on the Transport protocol + both adapters lets the runtime borrow the consumer's EXISTING lock: WSConsumerTransport.event_context on enter mirrors _handle_event_inner verbatim — await consumer._render_lock.acquire() (the existing object, not a new one), _processing_user_event = True, set the #1677 origin-channel contextvar to consumer.channel_name, start a PerformanceTracker + the SQL capture_for_event scope (websocket.py:3393-3400 / 3150-3154 / 3469-3475); on exit (finally) it resets the origin token, clears _processing_user_event, RELEASES the borrowed lock, and stops the SQL capture + tracker (websocket.py:4311-4313). SSESessionTransport.event_context is a no-op async CM (SSE runs single-threaded off the HTTP request — no concurrent tick/push loop to serialize against). The event handler+render body of _dispatch_event_inner is extracted into _dispatch_event_render and run inside async with self.transport.event_context(self.view_instance): (the view-mounted check stays OUTSIDE the context — a non-None view is needed to borrow its lock, matching WS, which acquires only after the view exists; a future actor-event branch will run OUTSIDE the context, matching WS where the actor block holds no lock). Zero WS-routing risk, no behavior change for current consumers: RUNTIME_OWNED_VERBS + _handle_event_inner are UNTOUCHED, and dispatch_url_change / _dispatch_url_change_inner are a SEPARATE path (untouched) — so this affects ONLY SSE events (the no-op context) and WS events (not routed through the runtime until the Phase 2.3b flip); url_change is unaffected. New direct-runtime suite python/djust/tests/test_transport_event_context_1899.py asserts the WS context borrows the consumer's EXISTING lock object (held inside, released after — incl. on exception), _processing_user_event True-inside/False-after, origin token set+reset, tracker current-inside/cleared-after; the SSE context is a no-op; ViewRuntime no longer owns a _render_lock; with a gate-off sibling (#1468 — a non-acquiring context makes the held-inside assertion go RED). The two existing source-grep pins (save-block gate, 5-grows enumeration) follow the body to _dispatch_event_render; four existing runtime transport mocks grow a no-op event_context.

  • The runtime event spine gained the three transport-agnostic per-event PERSISTENCE subsystems WebSocket has — time-travel record, session state-save (#1466), and sticky-child state-save (ADR-018) (#1894, ADR-022 Iter 2 Phase 2.2). Third PR of the 4-phase WS-event convergence split. ViewRuntime now records + persists per-event state the way the bespoke WS _handle_event_inner does, so the Phase 2.3 final flip (routing WS events through the runtime) persists identically: (1) time-travel recordrecord_event_start / record_event_end wrap the handler call in the single-view, component, and sticky-child branches, scoped per #1467 (component records on the PARENT view since LiveComponents have no separate buffer; a sticky-child records on the CHILD), finalized in a finally so a raising/permission-denied handler still appears in the debug panel; (2) session state-save #1466ViewRuntime._persist_state_after_event mirrors the WS save (private attrs first, then public get_context_data(), then components), gated on top-level-view identity AND enable_state_snapshot (#1552 — default views MUST NOT persist, since unconditional saves left async session I/O in flight that a host snapshot captured unrecoverably) and bounded by a 150ms asyncio.wait_for (#1475); (3) sticky-child state-save ADR-018ViewRuntime._persist_sticky_child_after_event persists a view_id-routed child under its stable sticky key on the both-opt-in predicate (sticky_child_should_persist), with the one-shot opt-in-mismatch warning (warn_sticky_child_optin_skip) in the else-branch. New Transport hook on_event_recorded(view, snapshot) replaces the WS _maybe_push_tt_event direct send: WSConsumerTransport delegates to the consumer's existing _maybe_push_tt_event (single-sourcing the DEBUG-gated time_travel_event frame), SSESessionTransport no-ops (no SSE debug panel today). A runtime-side #1466 source-grep pin (test_runtime_save_block_present_and_gated) asserts the SAME gate / key-shape / 150ms-bound strings the WS pin asserts, so drift between the two save gates goes red on whichever lost the string. No behavior change for current WS consumers — the WS save-block source in websocket.py is UNTOUCHED (the #1466/#1552 grep-pins in test_ws_reconnect_state_1465.py:119/313/320 stay green; event stays out of RUNTIME_OWNED_VERBS, the WS flip is Phase 2.3). New direct-runtime suite python/djust/tests/test_runtime_state_save_tt_1894.py (12 cases) drives runtime.dispatch_event against a MockTransport; each subsystem has a reproduce-first + gate-off pair (#1468) — removing the enable_state_snapshot gate makes a default view wrongly persist (RED), neutering the time-travel record drops the snapshot + hook (RED), and disabling the hook dispatch makes the on_event_recorded assertion fail (RED). Existing WS + runtime suites stay green (test_ws_reconnect_state_1465, test_sticky_child_recovery_1813, test_time_travel.py, test_time_travel_flow.py, test_runtime_child_routing_1892).

  • The runtime event spine gained the three transport-agnostic child-routing subsystems WebSocket has — component_id LiveComponent, view_id sticky-child, and embedded-child render (#1892, ADR-022 Iter 2 Phase 2.1). Second PR of the 4-phase WS-event convergence split. ViewRuntime._dispatch_event_inner now routes embedded children before the single-view path, mirroring the bespoke WS _handle_event_inner subsystems the runtime previously lacked entirely: (1) a view_id-targeted event resolves a sticky/embedded child via _get_all_child_views(), validates the handler against the CHILD, renders the child subtree, and emits a scoped embedded_update {view_id, html, event_name} frame — the client-supplied view_id is never echoed into the user-facing error (sanitize_for_log in the structured extra only, verbatim from WS); (2) a component_id-targeted event resolves a child LiveComponent via _components, validates the handler against the COMPONENT (not the parent), notifies the PARENT's waiters with component_id injected (ADR-002), and emits a parent-scoped full-HTML component_event frame — per #1467 it does NOT reassign the target view; (3) the embedded-child template render is single-sourced (the #1646 cure) — the pure render core, including the security-hardened escape + DEBUG-gate error path (CWE-79/CWE-209), is extracted verbatim into module-level websocket.render_embedded_child_html, the WS _render_embedded_child is now a thin delegating shim, and the runtime calls the same helper (one implementation, no parallel copy to drift). No behavior change for current WS consumers_handle_event_inner routing is untouched (WS events still flow through it; event stays out of RUNTIME_OWNED_VERBS, the WS flip is Phase 2.3) — and SSE is a structural no-op for both checks (no components/sticky → falls through to the single-view path). New direct-runtime suite python/djust/tests/test_runtime_child_routing_1892.py drives runtime.dispatch_event against a MockTransport with a real parent LiveView + sticky child + LiveComponent (TestRuntimeStickyChildRouting, TestRuntimeComponentRouting, TestRuntimeEmbeddedRender); each security-critical guard (component-handler validation, view_id log-sanitization, embedded-error escape) has a reproduce-first + gate-off pair (#1468), all three verified to go RED when the guard is removed. The existing WS child-routing suites (test_sticky_child_event_noop_1802, test_sticky_child_recovery_1813, test_waiter_component_propagation, test_time_travel_flow) stay green — WS path unchanged.

  • The runtime event spine grew toward WebSocket parity — ref echo, source/event_name, _force_full_html, _notify_waiters, and the #700 push-only skip (#1889, ADR-022 Iter 2 Phase 2.0). First PR of the 4-phase WS-event convergence split. ViewRuntime._dispatch_event_inner / _render_and_send (the minimal SSE event spine, SSE's only event path post-Iter-1) gained the transport-agnostic shared behaviors the bespoke WS _handle_event_inner has but the runtime lacked: (1) the client ref (#560) is now echoed back on BOTH the noop and every update frame, coerced to int (type-confusion guard); (2) the noop frame carries source="event" + event_name and the update frames carry source="event" for the client's #560 response-sequencing; (3) a handler that sets _force_full_html now defeats the auto-skip and sends a full html_update (patches discarded, flag consumed), mirroring websocket.py:4039-4040; (4) _notify_waiters (ADR-002 Phase 1b) runs after the handler so wait_for_event futures resolve on the SSE path too; (5) the #700 identity push-only auto-skip (the id()-identity variant beyond the assigns-snapshot skip) is ported, so a push-events-only handler emits a noop instead of a wasted re-render. No behavior change for current WS consumerswebsocket.py is untouched (WS events still use _handle_event_inner; event stays out of RUNTIME_OWNED_VERBS, the WS flip is Phase 2.3) — and SSE consumers gain the #560 ref/source fields. Each grow is reproduce-first + gate-off verified (#1468): new behavioral pins (TestEventSpineRefEcho, TestEventSpineForceFullHtml, TestEventSpineNotifyWaiters, TestEventSpineIdentityPushSkip) and a source-enumeration net (TestEventSpineEnumeration) in python/djust/tests/test_transport_behavioral_parity.py so a future drop re-forks RED; a real-SSE-transport end-to-end suite (TestSSEEventSpineParity in python/djust/tests/test_sse_runtime_convergence_1887.py, driving the /message/ endpoint which forwards the full ref-carrying envelope); and an extended RUNTIME_OWNED_VERBS contract pin (TestRuntimeOwnedVerbsContract::test_event_spine_grown_but_event_not_yet_ws_owned in python/djust/tests/test_ws_receive_runtime_dispatch_1852.py) pinning the Phase-2.0 ↔ 2.3 boundary.

  • The SSE transport's mount + event now route through the shared ViewRuntime, retiring the legacy bespoke SSE copies (#1887, ADR-022 Iter 1). The SSE GET-stream mount and the legacy /event/ POST previously had their own hand-written mount/event/render/async helpers (_sse_mount_view, _sse_handle_event, _sse_handle_event_inner, _sse_run_async_work) — a fork of the same dispatch logic the WebSocket and ViewRuntime paths carry, i.e. a live instance of the #1646 parallel-path-drift class. Both now dispatch through session.runtime.dispatch_mount / dispatch_event — the SAME spine the SSE /message/ endpoint and the WS url_change shim already use — and the legacy helpers (plus their orphaned flush/async/cache sub-helpers) are deleted. No behavior change for SSE consumers: mount still renders against the real authenticated request, events still stream patch/html_update frames, object-permission denial still blocks the mount (now via dispatch_mount's Iter-0 check), and start_async/@background work still streams its result (the runtime grew the async dispatcher SSE needs — this also fixes a latent legacy-SSE drop of start_async named-task work, since the legacy path only dispatched the never-set _async_pending format). SSE-specific behavior is preserved via two new SSESessionTransport hooks: build_request() (the runtime mounts against the real HTTP request, not a synthesized userless one) and on_view_mounted() (stamps _sse_session_id / _sse_session / session.view_instance). No websocket.py changes (WS convergence is Iter 2/3). New end-to-end integration suite python/djust/tests/test_sse_runtime_convergence_1887.py (mount / event / object-perm / start_async via the real endpoints, with gate-off witnesses, #1468); existing SSE + mount-chokepoint + has_ids-parity tests migrated to the converged path.

Fixed

  • An inline <script> (or <style>) inside the dj-root is no longer silently neutered by whitespace collapse, so its page JS actually runs on mount (#1927; the live-morph twin of #1848/#1871). TemplateMixin._strip_comments_and_whitespace — the single normalizer every render path runs (HTTP GET, WS mount, SSE/runtime, streaming) to match the Rust VDOM parser's whitespace pass — preserved whitespace only for <pre>/<code>/<textarea>, but the Rust parser ALSO preserves <script>/<style> (crates/djust_vdom/src/parser.rs:475). So the re.sub(r"\s+", " ") pass collapsed every newline inside an inline <script> onto ONE line; a leading // line comment then commented out the entire body, so the script's addEventListener / init never ran — with NO console error. This is why #1871's window.djust._runInsertedScripts mount-morph re-execution could not cure the symptom: the script was already neutered at render, before any morph re-execution. The fix adds <script>/<style> to the preserved-block set (the #1646 parallel-path-drift cure: the Python normalizer now matches the Rust parser's preserve set exactly), and — CRITICAL ORDERING — extracts the raw-text <script>/<style> blocks BEFORE the HTML-comment strip so an HTML-comment-looking token inside a JS/CSS body (var s = '<!-- x -->') is not mistaken for markup and stripped. Non-script/style whitespace collapse is unchanged. Diagnosed by driving the demo /demos/browser-smoke/ page in a real browser (the inline tab-toggle script's __smokeTabsWired stayed undefined until this fix); validated end-to-end in-browser (both the HTTP-GET parse AND the #1610 WS-mount morph now run the script, tab toggle works, no console error). New cases in TestStripCommentsAndWhitespace (python/djust/tests/test_strip_whitespace.py): the exact #1927 //-comment-led-body trigger, multi-script/style preservation, the comment-inside-script ordering guard, and a "non-script whitespace still collapses" non-regression. Gate-off (#1468) verified: reverting the <script>/<style> preservation collapses the body to one line and reds the comment-not-swallowed assertion. The now-blocking browser-smoke CI job (this PR) is the end-to-end validator.

  • A batched object/permission-denied mount no longer closes the SHARED WebSocket socket, killing the sibling mounts (#1922, #291-consistency). WSConsumerTransport.finalize_mount_auth closed the socket with code 4403 UNCONDITIONALLY on the permission_denied verdict, while gating the redirect verdicts (login-required / on_mount redirect) on not mounting_in_batch (the #291/#1780 multiplexed-path rule). Inside a mount_batch the socket is SHARED across sibling mounts, so a single object-level- or permission-denied view dropped the shared socket and collaterally killed the survivor mounts (the #291 failure class; pre-existing parity with the old bespoke handle_mount which also closed unconditionally). The permission_denied close is now gated on not self.mounting_in_batch too, so all blocking mount-auth verdicts share one batch-aware close. No security loss: the denied view is NOT mounted regardless — the runtime sends the error (permission_denied) frame and clears view_instance BEFORE finalize_mount_auth runs; only the transport-level socket close is suppressed in the batch case, so the denied view simply reports in failed[] exactly as the redirect case already reports in navigate[]. The denial holds; the siblings (which the client IS authorized for) are no longer dropped. A SINGLE (non-batch) denied mount STILL closes 4403 (mounting_in_batch is False outside a batch). New cases in test_ws_auth_close_socket.py (real WebsocketCommunicator, mirroring the #291 batch harness): test_mount_batch_with_objperm_denied_view_does_not_close_shared_socket (denied view → failed[], public sibling mounts, shared socket pongs = open) and test_single_objperm_denied_mount_still_closes_socket (over-gating guard). Gate-off (#1468) verified: reinstating the unconditional permission_denied close makes the batched-denial test go RED (the ping openness probe receives websocket.close instead of pong); the redirect-verdict gate and the single-mount close are unchanged.

  • Post-mount-flip cleanup — the DEBUG event-render residuals THE FLIP scoped out are now folded onto the runtime path, and the dead _extract_* consumer copies are removed (#1908, #1921). Two post-convergence cleanups from the WS event/mount flips (#1907/#1919), both inert in PRODUCTION. (#1908) DEBUG residuals: the deleted bespoke _send_update attached three things a runtime-routed WS event (which sends via transport.send directly) dropped — (1) the per-event _debug debug-panel payload (_attach_debug_payload, DEBUG + _debug_panel_active gated) plus the top-level timing / performance fields (gated on _should_expose_timing() = DEBUG or DJUST_EXPOSE_TIMING); (2) the no_patches context_snapshot the bespoke path passed to _emit_full_html_update; and (3) the cosmetic _current_event_name / _current_event_ref consumer attrs. A new Transport.on_event_frame(view, frame, *, event_name, event_ref) hook (SSE no-op) — called by _render_and_send in-place just before every patch / html_update event frame — attaches (1) via the consumer's existing _attach_debug_payload + _should_expose_timing (verbatim bespoke gate; performance from the event_context-borrowed PerformanceTracker; timing.render from a render-duration measured per event) and stamps (3); on_render_emitted grew a context param so the no_patches branch threads get_context_data() back into the snapshot (2), re-captured only under DEBUG so PRODUCTION never double-calls it. PRODUCTION byte-identical: every attached field is DEBUG/timing-gated, so a prod-mode WS event frame is unchanged (both were also absent in prod on the bespoke path); the internal _timing_render_ms marker is always popped before send and never reaches the wire. (#1921) dead code: the LiveViewConsumer._extract_cache_config / _extract_optimistic_rules copies had ZERO callers after the mount flip deleted the handle_mount body that called them (orphan-grep confirmed across python/ + tests/); ViewRuntime owns the live copies the mount frame uses. Removed; the runtime docstrings' stale "Mirror of LiveViewConsumer._extract_*" refs are corrected. No change to RUNTIME_OWNED_VERBS / routing; SSE unaffected. New cases in TestResidualFoldObservability + TestDebugResidualOnEventFrame (python/djust/tests/test_ws_event_flip_parity_1896.py): real-WebsocketCommunicator DEBUG-vs-PRODUCTION parity (a DEBUG event frame carries _debug, timing under expose-timing; a prod frame carries NEITHER _debug/timing/performance nor the internal marker) + direct-hook unit pins for the context snapshot, the consumer-attr stamp, the panel-closed/best-effort gates, and the #1921 deletion. Gate-off (#1468) verified: gating the on_event_frame fold + the context threading off makes the 8 behavior-meaningful tests RED.

  • The SSE /event/ alias now forwards the client-sent ref so the #560 ref echo works on BOTH SSE endpoints (#1891). The /message/ endpoint forwards the raw body verbatim to runtime.dispatch_message, so a client-supplied top-level ref reached dispatch_event and was echoed on the noop / update frame (#560, ADR-022 Iter 2 Phase 2.0). The legacy /event/ alias instead REBUILT the dispatch dict as {type, event, params} and DROPPED ref — so the runtime's _dispatch_event_render (which reads ref from the top level of the data dict) saw None and echoed nothing, leaving the end-to-end ref echo exercised only via /message/. DjustSSEEventView.post now carries ref through into the dispatch frame ({type, event, params, ref}); the runtime coerces it to int / None, so no endpoint-side validation is needed. params already carried _cacheRequestId / component_id / view_id (SSE has neither component nor sticky-child routing), so ref was the only dropped field. New cases in TestSSEEventAliasRefEcho (python/djust/tests/test_sse_runtime_convergence_1887.py, real-SSE end-to-end: update + noop frames echo the ref over the /event/ alias) and TestDjustSSEEventViewPost::test_forwards_ref_to_dispatch_event (python/tests/test_sse.py, the dispatch-dict pin). Gate-off (#1468) verified: reverting the rebuild to the pre-fix {type, event, params} shape makes the two echo tests RED while the gate-off witness (which re-drops ref to confirm absence) stays green.

  • component_id-routed WebSocket events now re-render the parent and emit html_update instead of erroring (#1898, fixed by #1907 THE FLIP). The deleted bespoke _handle_event_inner component_id branch resolved + ran the LiveComponent handler but never re-rendered the parent view: html stayed None, the html_update fallback stripped None and raised TypeError, and handle_exception turned it into an error frame — so a working component event surfaced to the client as an error with no DOM update. Now that WS events route through ViewRuntime.dispatch_event, the runtime's _dispatch_component_event (the Phase-2.1 port) re-renders the parent (component VDOM is separate from the parent's), emits a parent-scoped html_update carrying the parent's updated state (e.g. values pushed up via send_parent), and echoes the event ref. The #1896 parity net's component_id test is updated errorhtml_update (the single intended behavioral change of the flip); its gate-off sibling (a bogus component_id still errors Component not found at resolution) stays green, proving the positive test genuinely resolves a real component.

  • ViewRuntime now drains all 8 flush queues like the WebSocket path, fixing flash/page-metadata/layout/a11y/i18n silently dropped on SPA navigation (#1885 / #1646, ADR-022 Iter 0). The runtime drained only 3 of WebSocket _flush_all_pending's 8 turn-end queues (push_events / navigation / deferred), so its one production user — url_change (dj-patch click / popstate SPA navigation) — silently dropped flash messages, page-metadata (title/meta) updates, set_layout swaps, accessibility announcements, and i18n commands queued during handle_params() (a live parallel-path-drift instance, #1646, INSIDE the convergence target). The runtime now has a single _flush_all_pending that drains all 8 queues in WebSocket's exact canonical order (mirrors websocket.py:888), called from both turn-end sites (event render + url_change) so a future queue addition cannot be wired on one path and not the other. New behavioral-parity nets (TestFlushQueueParity, TestWireVersionParity, TestWsOnlyBehaviorEnumeration in python/djust/tests/test_transport_behavioral_parity.py) AST-pin the WS↔runtime flush-queue set + order, the wire-version stamping (#1858), and the known WS-only mount/event behaviors so future ViewRuntime-convergence drift re-forks RED. Reproduce-first + gate-off (#1468) verified: removing the 5 added flush lines reproduces the pre-fix 3-of-8 state and the parity net detects exactly the missing {flash, page_metadata, pending_layout, accessibility, i18n}.

  • Systemic test-isolation: one autouse fixture resets djust's process-globals between tests, retiring the shared-global flaky class (#1883, #1882). Three shared-process-global test-pollution flakes in two milestones were all the SAME class — a process-global left dirty across tests in an xdist worker: #1862 (ROOT_URLCONF leak, PR #1874), #1875 (djust_hotreload channel-layer pollution, PR #1881), and #1882 (process-global wire-version drift — a stray djust_hotreload frame on the cached InMemoryChannelLayer re-renders on a later consumer and bumps its per-connection _next_version() counter, so test_time_travel_jump_recovery_version_is_current saw the jump land at version 4 instead of 3 under -n auto). Each was whack-a-moled per-test. The systemic cure is a new shared helper djust.test_isolation.reset_djust_globals() (DRY, #1646) called by an autouse _reset_djust_globals fixture in BOTH test roots (tests/conftest.py, mirroring cleanup_session_cache; and python/djust/tests/conftest.py) that resets djust's leak-prone process-globals BEFORE each test: the Channels layer manager (channel_layers.backends.clear() — the #1875/#1882 class), Django's URLconf caches (clear_url_caches() + set_urlconf(None) — the #1862 class), djust's route-map cache (_reset_route_map_cache()), and the module-level itertools.count id counters (mixins.sticky._view_id_counter, components.templatetags.djust_components._tooltip_id_counter). It is deliberately conservative (runs on every test): it resets ONLY state that genuinely leaks and is lazily re-derived, with lazy imports wrapped so a missing optional dep (Channels) never errors the fixture; it does NOT touch state_backend (already isolated by cleanup_session_cache), the keyed self-invalidating _jit_serializer_cache, the one-shot _CUSTOM_FILTERS_BRIDGED bootstrap, or per-instance StickyChildRegistry._child_views. The #1882 cure is proven deterministically + gate-off (#1468) in python/djust/tests/test_global_isolation_1883.py: a stale-layer sibling group_send reproduces the exact got 4 drift WITHOUT the reset and the clean 1 -> 2 -> 3 chain WITH it, plus per-global unit pins (neutering reset_djust_globals fails 5/8 cases). Verified with the 3-clean-runs gate (#1174): full suite -n auto × 3 (plus × 3 bonus) all clean, 8163 passed / 0 failed each run — the fixture breaks no existing test.

  • De-flaked the 17 #1721 theme-tag tests under -n auto — the systemic #1883 fixture now re-asserts the ready()-time Rust tag handlers (#1928, #1883-class). python/djust/tests/test_theme_tags_rust_engine_1721.py flaked under full -n auto: has_tag_handler("theme_panel") returned False and all 17 tests 500'd with Unsupported template tag '{% theme_panel %}'. Root cause is the same shared-process-global class as #1883: the process-global Rust tag-handler registry (crates/djust_templates/src/registry.rs) is shared across an xdist worker, and DjustThemingConfig.ready() / DjustComponentsConfig.ready() register the {% theme_X %} / {% render_slot %} handlers only ONCE per process. tests/benchmarks/test_tag_registry.py::TestRustPythonInterop clears the registry (clear_tag_handlers()) and its restore_registry fixture restores ONLY the djust.template_tags built-ins — not the app-registered theme/component handlers — so once it runs in a worker the theme handlers stay gone for every later test (also reproducible by any test that django.setup()s without djust.theming). This is the exact #1771 bug fixed only in tests/unit/test_tag_registry.py (parallel-path drift, #1646); the benchmark twin was uncovered. Systemic cure: reset_djust_globals() (python/djust/test_isolation.py) grows _reset_rust_tag_handlers(), which re-runs both ready()-time registrars BEFORE every test in both test roots — idempotent (theming guards on has_tag_handler, component overwrites) and a no-op without the Rust extension, so it is cheap. Retires the whole flaky class regardless of which polluter ran, rather than patching the one benchmark file. New cases in python/djust/tests/test_global_isolation_1883.py: test_reset_reasserts_theme_and_component_tag_handlers_1928 (clear → prove gone → reset → prove restored) + test_gate_off_clear_without_reset_loses_theme_handler_1928 (gate-off sibling proving the bare clear loses the handler, non-tautological per #1468). Reproduce-first verified: the benchmark-polluter-then-theme order failed 17/18 pre-fix and passes 18/18 post-fix; gate-off (#1468) verified (neutering _reset_rust_tag_handlers() re-reds both the repro order and the new pin). 3-clean-runs gate (#1174): full suite -n auto × 3 all clean (8604 passed / 0 failed each).

  • De-flaked test_mount_batch_with_login_view_does_not_close_shared_socket under -n auto (#1875). The #291 regression test (a login-redirecting view in a mount_batch must NOT close() the shared socket) was order-fragile under full -n auto saturation — it failed 1 of 3 full runs, passed in isolation. Two independent races, both fixed without weakening the guard: (1) the consumer joins the process-global djust_hotreload channel-layer group on connect, so a sibling test's group_send("djust_hotreload", ...) could deliver a stray frame into the test's receive_nothing window — now isolated by clearing the cached channel-layer backend so the consumer connects to a fresh, unpolluted InMemoryChannelLayer; (2) the receive_nothing(timeout=0.5) "no mid-batch close" check raced a wall-clock window (flaky under CPU saturation per the #1830/#1795 flaky-timing canon) — replaced with a deterministic pingpong openness probe (a closed socket cannot pong). Gate-off verified (#1468): removing the _mounting_in_batch close-suppression guard makes the test fail (Expected type 'websocket.send', but was 'websocket.close'). Verified with the 3-clean-runs gate (#1174): full suite -n auto × 3 all clean.

  • V004 no longer false-fires on framework-invoked lifecycle hooks (#1684). The V004 system check ("public method looks like an event handler but is missing @event_handler") flagged user overrides of hooks the framework calls directly (self.X() / getattr / hasattr) rather than through the user-event router — these must NOT carry @event_handler, but their names match the event-handler-like regex and were absent from the V004 lifecycle-skip set in checks/components.py. Canonical symptom: handle_presence_leave (bit djust-org/djust-start#5). Added the 8 framework-invoked hooks (handle_presence_join/handle_presence_leave/handle_cursor_move/handle_tick/handle_async_result/handle_component_event/handle_info/on_wizard_complete) to the skip set. The fix originally landed on the 1.1 branch (#1685) against the pre-#1822-split checks.py; it was never ported to main's split checks/ (so the false-positive was live through 1.0.8) — this lands it on main. New regression TestV004LifecycleMethods::test_v004_ignores_framework_invoked_hooks_1684 (gate-off verified, #1468).

Security

  • ViewRuntime.dispatch_mount gained the signed state-snapshot HMAC restore + emit WebSocket has — byte-identical caps — and it goes LIVE for the SSE mount path (#1913, ADR-022 Iter 3 Phase 3.1). The opt-in state-snapshot feature (enable_state_snapshot = True) restores a view's public state from a client-echoed payload on back-navigation in lieu of mount(); the payload is a server-signed TimestampSigner blob (CWE-345 → CWE-915) whose restore is the SECURITY BOUNDARY. The runtime mount path — which is the SSE mount path since Iter 1 (#1887) — previously had NO snapshot restore at all, so converging SSE onto it without porting the restore would either drop the feature for SSE or (worse, if added carelessly) open an unsigned-snapshot injection vector. dispatch_mount now ports the WS restore VERBATIM (websocket.py:2491-2587): the same unsign_snapshot(blob, slug=view_path, sid=session_key) HMAC binding (a snapshot signed for view A / session S1 / older than DJUST_STATE_SNAPSHOT_MAX_AGE does NOT restore), the same size cap (64 KB verified inner JSON), keyset cap (256 keys), dict-type cap, the DJUST_STATE_SNAPSHOT_ENABLED operator master-switch, and the _should_restore_snapshot(request) view-level veto. The session key for the sid binding is sourced from request.session and stamped on the view (_django_session_key) so the runtime/SSE path validates the SAME session binding the WS path does. The matching emit (sign_snapshot on the mount frame, websocket.py:2754-2792) is also ported, opt-in only. Gated enable_state_snapshot — default views never restore or emit (#1552); for SSE the restore is a no-op unless the view opts in AND a snapshot is present. WS UNTOUCHEDhandle_mount keeps its own copy until the Phase 3.3b flip; RUNTIME_OWNED_VERBS / WS routing / handle_mount_batch are unchanged (websocket.py has no diff). New suite python/djust/tests/test_runtime_mount_state_restore_1913.py — doc-claim-verbatim HMAC-caps TDD (#1046): a snapshot signed for a different view / a foreign session / past the TTL / forged-unsigned / tampered / oversized / over-keyset / vetoed does NOT restore via the runtime path (state stays at the mount() default), each with a gate-off sibling (#1468). Gate-off verified: skipping the slug cap in unsign_snapshot makes the cross-view restore wrongly succeed (RED); gating the runtime restore/emit/hook-redirect off makes the corresponding tests RED. The existing WS pins (test_state_snapshot_signing.py, test_ws_reconnect_state_1465.py) stay green.

  • ViewRuntime gained a transport.recheck_event_auth(view) hook for opt-in per-event auth re-check (reauth_on_event, #1777 threat-model T3), and it goes LIVE for SSE (#1905, ADR-022 Iter 2 Phase 2.3a). Auth runs once at mount and the mount-time principal is cached on the session, so a user who logs out / loses a permission mid-session would keep dispatching events on the open connection until they reconnect. The bespoke WS handle_event already re-checks per-event auth when LIVEVIEW_CONFIG['reauth_on_event'] is set + the view requires auth (websocket.py:3193-3222), but the runtime had no equivalent — so the SSE event path (converged onto the runtime since Iter 1, #1887) had NO mid-session deauth gate at all. New Transport.recheck_event_auth(view) -> bool (default-True = no re-check) wired into ViewRuntime._dispatch_event_inner at the SAME point WS does — after the view-mounted check, BEFORE the actor branch and the handler. WSConsumerTransport replays the WS bespoke logic verbatim (re-resolve the user from the scope session via channels.auth.get_user, reflect onto view.request.user, re-run check_view_auth_lightweight; on failure navigate to the login url + close(4403)). SSESessionTransport re-checks against the LIVE event-POST request (session._event_request, stamped by the /event/ + /message/ endpoints just before dispatch — the current POSTer's request.user, not the stale mount request) — covering the case owner-binding (Finding #24) cannot: a still-authenticated, still-owning POSTer whose permission was revoked mid-session — and on failure sends an auth-error frame + ends the stream. Both fail-safe (any error skips the re-check, never breaks an event) and gated on reauth_on_event + login_required/permission_required (default views pay nothing). #291 multiplexed-path care: the runtime clears view_instance UNCONDITIONALLY on a False return (the state change that closes the security gap — no later frame on the session dispatches against the deauthorized view); the transport-terminating close is OWNED + gated by the hook (events are not batched today — mount_batch is mount-only — but the close stays gateable if events are ever collected, matching the WS bespoke view_instance = None after close). LIVE for SSE; DORMANT for WS — WS events still run on the bespoke _handle_event_inner (which keeps its own inline re-check) until the Phase 2.3b flip; RUNTIME_OWNED_VERBS / WS routing are UNTOUCHED, websocket.py's reauth block is unchanged. New suite python/djust/tests/test_runtime_reauth_async_1905.py (TestSSEReauthOnEvent, TestReauthHookShape291, TestWSReauthAdapterPort): real-SSE end-to-end (mount with a permission, POST with it revoked → refused + error frame + stream end + view_instance cleared; still-authorized → renders; default-OFF → no re-check) + the #291 shape (state cleared even when the close is gated, via a fake transport) + the WS-adapter port. Reproduce-first + gate-off (#1468) verified: gating the recheck off makes the deauthorized SSE event wrongly render (RED) and the #291 state-clear assertion fail (RED). test_event_reauth_1777 (the bespoke WS path) stays green.

  • Closed a latent object-permission gap (IDOR-class) in ViewRuntime.dispatch_mount before it could go live (#1885, ADR-022 Iter 0). The WebSocket handle_mount enforces the ADR-017 post-mount object-permission check (check_object_permission), but ViewRuntime.dispatch_mount did not — so a view whose has_object_permission() returns False (or whose get_object() denies) would have mounted, rendered, and sent the denied object to the client through the runtime path. The gap was not yet exploitable (dispatch_mount has zero production call sites today), but Iter 1 of the ViewRuntime convergence (routing SSE through the runtime) would have made it live. The runtime mount now routes through the SAME shared enforce_object_permission chokepoint the other transports use (runtime.py, mirroring websocket.py:2554-2573), placed AFTER mount() (so get_object() can read URL-derived attrs) and BEFORE handle_params + render (so a denied object is never rendered or sent). Fail-closed; a no-op for views without a custom get_object (behavior-preserving). Reproduce-first + gate-off (#1468) verified: a denied view mounts + leaks its rendered HTML before the fix, emits only a permission_denied error frame after. New cases in TestDispatchMountObjectPermission (python/djust/tests/test_transport_behavioral_parity.py).

[1.0.8] - 2026-06-23

Security

  • sanitize_for_log now carries a CodeQL-recognized CR/LF barrier (#2465/#2466 — py/log-injection). The first log-hardening pass wrapped the gate-rejection paths in sanitize_for_log, but its isprintable()-loop (which maps line breaks to ?) is not a modeled CodeQL barrier, so the sanitize_for_log(request.path) calls in api/openapi.py + observability/views.py stayed flagged (the alerts re-numbered as the lines shifted). The helper's return value is now an explicit .replace("\r", "").replace("\n", "") — the form CodeQL recognizes as a log-injection sanitizer — which clears every sanitize_for_log(<remote source>) call site. It is a runtime no-op (line breaks are already mapped to ? upstream), so all existing behavior and tests are unchanged. Pinned by test_log_sanitizer_barrier_pin (python/djust/tests/test_log_sanitization.py) so a "looks redundant, remove it" refactor can't silently re-open the alert (#1859); gate-off verified (#1468).
  • Logging hardened against three CodeQL findings (no behavior change for legitimate input). (1) py/clear-text-logging-sensitive-data (HIGH, CodeQL alert 2421) — the DJUST_TRUSTED_PROXY_COUNT misconfiguration warning in python/djust/_client_ip.py now logs the offending value's TYPE (type(raw).__name__), not the raw settings value (CodeQL treats settings reads as a sensitive source); the type is the actionable diagnostic ("you set a str, expected int") without echoing a config value to the log stream. (2)+(3) py/log-injection (MEDIUM, CodeQL alerts 2422 + 2423)api/openapi.py:_openapi_gate and observability/views.py:_gate now wrap the user-controlled request.path with the existing djust._log_utils.sanitize_for_log (CR/LF/control-char strip + 200-char truncate) before logging the gated-request warning, so an attacker cannot forge log lines via a crafted path. Uses the same sanitizer already applied across api/dispatch.py (no parallel-path drift, #1646). Regression: python/djust/tests/test_codeql_log_hardening.py — 3 reproduce-first cases (bad proxy-count value; CRLF-injected path on each gate), gate-off verified (#1468; reverting each fix turns its test red).
  • Dependency security bumps — 9 Dependabot alerts resolved (lockfile-only, no code change). pip (uv.lock, runtime): msgpack 1.1.2 → 1.2.1 (GHSA-6v7p-g79w-8964 — HIGH; out-of-bounds read / crash on Unpacker reuse after a caught error — msgpack is on djust's WS wire-protocol path), ujson 5.12.1 → 5.13.0 (CVE-2026-54911 / GHSA-3j69-69wj-xqx2 — malformed/truncated UTF-8 silently rewritten in dumps()), pydantic-settings 2.14.0 → 2.14.2 (GHSA-4xgf-cpjx-pc3j — NestedSecretsSettingsSource symlink-follow outside secrets_dir). npm (package-lock.json, dev/test — transitive via jsdom): undici 7.25.0 → 7.28.0, closing six advisories at once — CVE-2026-9697 / GHSA-vmh5-mc38-953g (HIGH, TLS cert-validation bypass via dropped requestTls in SOCKS5), CVE-2026-6734 / GHSA-hm92-r4w5-c3mj (HIGH, cross-origin request routing via SOCKS5 pool reuse), CVE-2026-9678 / GHSA-pr7r-676h-xcf6 (cache whitespace bypass), CVE-2026-9679 / GHSA-p88m-4jfj-68fv (Set-Cookie header injection), CVE-2026-6733 / GHSA-35p6-xmwp-9g52 (keep-alive response queue poisoning), CVE-2026-11525 / GHSA-g8m3-5g58-fq7m (Set-Cookie SameSite downgrade). All patched versions are within existing dependency constraints (no pyproject.toml / package.json change). Full pytest suite (incl. the wire-protocol / WebSocket paths exercising msgpack) + the 1743-case JS suite green on the bumped versions.
  • Made the _ALWAYS_EXCLUDED_FIELDS serialization floor UNCONDITIONAL — a per-model allowlist can no longer re-expose password/is_superuser/is_staff (#1868 — CWE-200/CWE-359). serialization.py:_field_is_serializable checked the per-model djust_serializable_fields allowlist BEFORE the _ALWAYS_EXCLUDED_FIELDS floor, so a model declaring djust_serializable_fields=['password'] (or ['is_staff'], ['is_superuser']) re-exposed those hardcore-sensitive fields to the client — the exact secure-by-default question surfaced by the #1867 SECURE_DEFAULTS.md review. The precedence is now flipped: the floor wins first (a floor field is dropped even when an allowlist names it), and the allowlist may only NARROW the remaining, non-floor set. The ONLY way to re-include a floor field is the new deliberate, loudly-named per-model opt-out djust_serialize_sensitive_fields — a developer must explicitly take ownership of shipping a password hash / privilege flag (default is always deny). The single _field_is_serializable function gates all three auto-serialization callers (model fields, get_* methods, @property values), so the fix is uniform with no parallel-path drift (#1646). Default-behavior change (action required for some apps): a model that re-exposed a floor field via djust_serializable_fields will no longer ship it; add the field to djust_serialize_sensitive_fields (a separate, explicit declaration) if that exposure was intentional. Regression: new cases in TestUnconditionalFloor (python/djust/tests/test_serializer_field_exposure_f19.py) — allowlist-cannot-reexpose-password, allowlist-cannot-reexpose-privilege-flags, allowlist-still-narrows-non-floor-fields, explicit-opt-out-reincludes-only-named-field, opt-out-without-allowlist-lifts-floor; reproduce-first + gate-off verified (#1468) — restoring the allowlist-wins precedence fails 4/5 including test_allowlist_cannot_reexpose_password.
  • Enforced object-permission (ADR-017) on the HTTP-API + SSE-legacy mount paths (#1857 — CWE-862 / IDOR). The post-mount object-permission check (get_object + has_object_permission, via the shared djust.auth.core.enforce_object_permission chokepoint) was enforced on the WS mount, runtime/url_change, HTTP-GET, and {% live_render %} paths (#10/#11/#12) but had two remaining gaps: (a) api/dispatch.py:dispatch_api and dispatch_server_function mounted an object-scoped view and ran check_view_auth / check_handler_permission but never the object-level check, so an expose_api handler / @server_function ran against a denied object (IDOR on the HTTP-API transport); and (b) sse.py:_sse_mount_view (the legacy SSE mount) had zero object-perm calls, so it rendered a denied object's initial HTML (IDOR on the SSE transport). Both now call enforce_object_permission(view, request) after view-level auth + mount() (so get_object()'s access-determining state exists), before the handler runs / the render: the API paths return 403 permission_denied and the SSE path pushes an error frame + aborts the mount (return False). Object-level authorization is now uniform across every mount/render entry point. The check is a pure no-op for views without a custom get_object (behavior-preserving for non-object-scoped views) and fail-closed on denial / a None request / any non-PermissionDenied exception. Regression: new cases in python/djust/tests/test_object_perm_api_sse_paths.py (test_api_dispatch_denies_forbidden_object, test_server_function_denies_forbidden_object, test_sse_mount_denies_forbidden_object, plus permitted-object + non-object-scoped no-op cases per path); reproduce-first + gate-off verified (#1468) — neutering each enforce_object_permission call turns the matching denies test red. The concern-4c structural pin in python/djust/tests/test_mount_chokepoint_structural.py::TestMountOrchestrationChokepoint is extended to assert enforce_object_permission is referenced on sse.py (>=1) and api/dispatch.py (>=2 call sites) so a future removal is caught.
  • WS receive() routes the url_change verb through the single ViewRuntime.dispatch_message chokepoint (#1852). LiveViewConsumer.receive() previously dispatched url_change straight to dispatch_url_change, BYPASSING dispatch_message — the chokepoint the SSE transport already routes every inbound frame through. It now routes url_change via _dispatch_runtime_ownedViewRuntime.dispatch_message (runtime.py) so a future security/policy control added at that chokepoint auto-applies to the WebSocket transport. Adds RUNTIME_OWNED_VERBS = frozenset({"url_change"}) as the explicit pinned chokepoint set plus a documented WS-only extension set in receive(). mount (sticky/snapshot/actor — deferred to T1-A #1853) and event (~16 WS-only behaviors the runtime path lacks) deliberately stay on their WS handlers. Behavior-preserving; wire output for url_change is identical. New cases in TestUrlChangeRoutedThroughChokepoint, TestUrlChangeEndToEndPreserved, TestWSOnlyFramesPreserved, TestRuntimeOwnedVerbsContract (python/djust/tests/test_ws_receive_runtime_dispatch_1852.py), gate-off-verified (#1468) via a real WebsocketCommunicator spy on dispatch_message.
  • Anti-drift net: parity axes for auth/object-perm/rate-limit/origin + mount-orchestration structural pin (#1850, #1851). Extended the WU1 anti-drift test nets so a future regression that lets one transport's security control drift from the others (or a #1853 migration that silently re-grows a parallel mount orchestration) is caught mechanically. TestViewAuthParity, TestObjectPermissionParity, TestRateLimitParity, and TestOriginParity (in python/djust/tests/test_transport_parity_security.py) each assert an IDENTICAL security verdict across the ws/runtime/sse transports at the shared-helper level — view-level auth (check_view_auth), object-level permission (enforce_object_permission), per-handler @rate_limit trip point (caller_key + handler_rate_check), and foreign-Origin/Host rejection (_is_allowed_origin / _host_in_allowed_hosts). TestMountOrchestrationChokepoint (in python/djust/tests/test_mount_chokepoint_structural.py) adds an AST count-canary pinning that websocket.py + runtime.py each still reference those shared mount-orchestration security calls (a fourth drift class alongside the existing dynamic-import / setattr / RequestFactory chokepoint scans). Each new assertion is gate-off-verified (#1468). Test-only change — no API or behavior change.
  • Single-sourced the shared pre-mount auth sequence across the WebSocket, runtime, and SSE mount paths (#1853). The pre-mount security SEQUENCE — view-level auth (check_view_auth) then, on auth success, tenant resolve (_ensure_tenant) + tenant ContextVar bind — was hand-copied in LiveViewConsumer.handle_mount (WS), ViewRuntime.dispatch_mount (runtime), and _sse_mount_view (legacy SSE). It is now extracted into one helper, djust.auth.core.run_pre_mount_auth, that all three paths route through, so a future edit cannot reorder the steps or drop one on a single path (parallel-path drift, #1646). The helper owns ONLY the sequence; each transport keeps its own verdict→envelope mapping (WS close 4403 / runtime + SSE error/navigate frame), and all WS-only mount mechanics (sticky-child, signed state_snapshot restore, actor wiring, render) are untouched. Behavior-preserving: the helper returns exactly what check_view_auth returns and propagates PermissionDenied / _ensure_tenant exceptions, so each caller's existing envelope is unchanged; tenant resolve/bind is skipped on auth denial as before. Hardening side effect: a non-PermissionDenied error during the runtime/SSE auth call previously logged-and-PROCEEDED (a latent fail-open gap) and now aborts fail-closed with a mount-error envelope, matching the WS path which already aborted. The post-mount object-permission (check_object_permission), url-change object-permission (enforce_object_permission), and reconstructed-Host binding (validated_host_from_scope) were deliberately left in place (not part of the pre-mount sequence). The group-1 concern-4 structural pin is strengthened to assert all three transports route through run_pre_mount_auth and the helper body still invokes its leaf chokepoints. New cases in TestRunPreMountAuthHelper, TestWebSocketMountSequence, TestRuntimeMountSequence, TestSSEMountSequence, TestCrossPathVerdictParity (python/djust/tests/test_mount_security_sequence_1853.py) + TestMountOrchestrationChokepoint (python/djust/tests/test_mount_chokepoint_structural.py); gate-off-verified (#1468) — neutering run_pre_mount_auth to always-allow fails the auth-denied test on WS, runtime, and SSE simultaneously.

Added

  • Two new secure-default system checks (#1854) — S009 (event-handler-needs-auth) and S011 (inline-script / CSP). Both live in python/djust/checks/security.py and reuse the existing @register("djust") + _has_noqa + _is_liveview_subclass scaffold. S009 (Warning, AST) flags a LiveView that declares VIEW-level authorization (a truthy login_required / permission_required class attr, a check_permissions override, a Django/djust AccessMixin-family base, or an auth-gated dispatch) yet exposes a PUBLIC @event_handler / @action method with NO per-handler gate (@permission_required) and no class-level check_handler_permission override — a user past the mount gate could call an ungated sensitive handler. It is conservative: private (_) handlers and read-only-looking handlers (load_/get_/list_/…) are exempt, falsy auth attrs don't count, and # noqa: S009 / DJUST_CONFIG['suppress_checks'] silence it. S011 (Warning, template scan) flags an inline executable <script> INSIDE a real dj-root / dj-view subtree when no CSP is configured (no django-csp middleware, no CONTENT_SECURITY_POLICY / CSP_* / SECURE_CSP* setting) — targeting the #1848 class (morphdom does not re-execute inserted <script>, so inline page JS inside the dj-root silently never runs) plus the CSP gap. It is low-false-positive: it skips src-includes, nonce-bearing scripts, and data blocks (application/json, text/template, …), blanks <pre>/<code> example markup, balances the dj-root subtree so page scripts AFTER the root (e.g. a post-root {% block extra_scripts %}) are not flagged, and uses (?<![\w-]) attr anchors so data-src/data-type/data-nonce aren't mistaken for the real attributes (#1517 hardening). S010 (rate-limit-presence) was intentionally NOT shipped — the prevention plan marks it advisory/opt-in only (high false-positive risk). Empirical canary (#1459) + gate-off self-test (#1468): new cases in TestS009EventHandlerNeedsAuth and TestS011InlineScriptCsp (in python/tests/test_checks.py). Dogfooded (#1060) against examples/demo_project with zero false positives.
  • Browser-smoke canary for the #1848/#1849 runtime-break class (#1855, closes #1849). The pytest suite is structurally blind to runtime/wiring breaks — a refused WS mount and an inline-<script>-inside-dj-root that the mount morph never executes both shipped in 1.0.7 with the 8237-passing suite + an HTTP-200 smoke green. New tests/playwright/test_browser_smoke.py drives a dedicated /demos/browser-smoke/ LiveView (BrowserSmokeView + template, in examples/demo_project/djust_demos/) with two assertions: (A) a mount canarydj-click="bump" round-trips 0 -> 1, proving the LiveView actually mounts over the WebSocket (catches a mount refusal, #1849 class 1); (B) an inline-script canary — an inline <script> INSIDE the dj-root wires a delegated tab toggle that flips .active (the #1848 class 2). Because #1848 is an OPEN framework bug in 1.0.7, the exact known signature (the inline script never executed) is a tolerated KNOWN-XFAIL — the canary becomes a hard regression guard once #1848 lands, while a genuinely-new break (mount refusal, or inline script ran but toggle broke) hard-fails now. Ships in the already-non-blocking playwright-tests leg (continue-on-error, NOT in the test-summary AND-gate) per #1534 — it must go green on a runner before any promotion to a hard merge gate. Verified locally against the running demo: mount canary passes, #1848 reproduces live as the tolerated xfail, and a control proves the assertion is correct behavior (a delegated listener outside the dj-root catches the same clicks).
  • docs/SECURE_DEFAULTS.md — secure-by-default pattern catalog + PR-checklist subsection + audit cadence (#1856). Documents the four proven secure-by-default patterns so feature authors copy the canonical shape instead of re-deriving the controls: (1) denylist serialization (serialization.py _resolve_sensitive_fields() floor + DJUST_SENSITIVE_FIELDS union + per-model djust_exclude_fields/djust_serializable_fields), (2) HMAC signed snapshots (security/state_snapshot.py sign_snapshot/unsign_snapshot — TimestampSigner, slug+session binding, fail-closed None on tamper/expiry — plus the private-attr signing boundary: live_view.py _capture_snapshot_state excludes _* attrs from the signed blob and _restore_private_state restores them UNSIGNED from the server-side session, so don't store auth/ownership/PII in _* expecting integrity), (3) fail-closed precedence gate (api/openapi.py:_openapi_gate DEBUG→opt-in-setting→authenticated→non-disclosing-404, sibling observability/views.py:_gate, and auth/core.py:run_pre_mount_auth as the mount-auth single-source), and (4) safe_setattr (security/attribute_guard.py dunder/private/format guard for client-controlled keys). Adds a "make a NEW feature secure-by-default" section and a docs-only quarterly audit cadence (re-inventory transport chokepoints, re-run test_transport_parity_security.py + test_mount_chokepoint_structural.py against new transports). The one-line "Secure defaults" item in docs/PULL_REQUEST_CHECKLIST.md is expanded into a subsection cross-referencing the four patterns (mirroring the "Transport chokepoint (#1646)" format) with fail-closed + transport-parity prompts; both docs are indexed from docs/README.md (new Security section) and CLAUDE.md Additional Documentation. Every cited file:symbol was grep-verified at write time (#1197) and the four patterns' runtime behaviors empirically confirmed (denylist floor, safe_setattr block/allow matrix, snapshot sign/verify + tamper/cross-view rejection).

Changed

  • Pre-release security audit: Bandit now BLOCKS on new high-severity findings (#1855). The pre-release-security-audit.yml workflow ran Bandit with || true (purely advisory). A new "Bandit high-severity gate (blocking)" step fails the job on any high-severity finding outside the repo's reviewed skip list — using the SAME -s B703,B308,B324,B301,B102 + test-dir exclusions the pre-commit Bandit hook uses (single source of truth), so the reviewed baseline is 0 high-severity and only NEW high-sev blocks a release. The reviewed exceptions are the framework's documented ones (mark_safe in templates, MD5 for non-security cache keys, pickle/exec for JIT, a bidi-char fixture in an upload-safety test). Dependency-CVE scanners (safety / pip-audit / cargo-audit / npm audit) stay advisory. Empirically verified non-tautological: 0 high-sev with the reviewed skips (passes), 3 with skips removed (fails), 1 on a synthetic subprocess(shell=True) trigger (fails).

Fixed

  • CodeQL code-quality cleanups (Note severity). Removed the unused module-global logger (and its now-orphan import logging) from python/djust/checks/utils.py (py/unused-global-variable, CodeQL alert 2418), and converted the Transport Protocol's bare ... stub bodies in python/djust/runtime.py to one-line docstrings — the protocol methods are now documented and CodeQL py/ineffectual-statement (alerts 2463 / 2464) no longer flags the Ellipsis statements. No behavior change (the concrete WSConsumerTransport / SSESessionTransport implementations are untouched).
  • Test-ordering pollution: TestT016DjNavigateWithoutRoutes leaked ROOT_URLCONF, breaking TestDemoRegistration under -n auto (#1862). The four T016 check tests in python/tests/test_checks.py combined @override_settings(ROOT_URLCONF=...) with the pytest-django settings fixture parameter AND a fixture mutation (settings.TEMPLATES = ... inside _set_template_dir) in the same test. The two settings-restoration mechanisms race at teardown and the @override_settings value wins, so ROOT_URLCONF stayed pinned at the routeless test URLconf (tests.api_test_urls_unmounted) for the rest of the xdist worker. When tests/unit/test_demo_views.py::TestDemoRegistration landed in the same worker afterward, its four resolve() tests (test_pwa_view_in_urlconf, test_tenant_view_in_urlconf, test_service_worker_url_resolves, test_manifest_url_resolves) raised Resolver404. Fix: the T016 tests now set ROOT_URLCONF via the single settings-fixture mechanism (settings.ROOT_URLCONF = ...) instead of @override_settings, so one restoration path handles every mutated setting. Defense-in-depth: TestDemoRegistration.setup_method now calls django.urls.clear_url_caches() so it never depends on a clean resolver cache. New regression cases in TestT016DoesNotLeakRootUrlconf (gate-off-verified: re-introducing the @override_settings+fixture-mutation combo fails the leak assertion). Reproduced deterministically and verified with 3 consecutive clean full-suite runs under -n auto.
  • Inline classic <script> inside the dj-root now executes after the WS-mount morph (#1848 — 1.0.7 regression). On mount, djust HTTP-pre-renders then MORPHS the pre-rendered DOM against the WS-mount HTML (#1610: morphChildren); the non-prerendered branch assigns container.innerHTML = data.html. Per HTML spec, a <script> inserted by clone+insert (morph) or by innerHTML is parsed but NOT evaluated, so an inline page <script> inside the dj-root (e.g. one registering a delegated document click listener for tab switching / code-copy buttons) silently never ran — no console error. Regressed in 1.0.7 when #1610 began morphing the prerender DOM (worked on 1.0.5rc3). Fixed with a single window.djust._runInsertedScripts(container) helper (python/djust/static/djust/src/03-websocket.js) that re-creates each classic <script> via document.createElement('script') + copy attributes + textContent + replaceWith (the only DOM op that makes the browser run an already-in-tree inert script), called after both mount branches (parallel-path-drift cure, #1646). Classic-only: type="djust/hook" colocated definitions and application/json / importmap are left untouched. Idempotent via a data-djust-script-ran marker so a WS reconnect / re-mount on the same DOM does not double-execute. Emits no framework-generated inline script (CSP-safe). This closes the #1855 browser-smoke inline-script canary's tolerated known-xfail (it becomes a hard regression guard now that #1848 has landed). New cases in the #1848 — inline <script> inside dj-root executes after mount morph describe block (tests/js/mount-morph-script-exec-1848.test.js, 6 cases); repro-first builds the dj-root via innerHTML with inter-element whitespace + real <script> nodes (#1650 fidelity), drives the real helper from the built bundle, and asserts a delegated document click listener registers; gate-off-verified (#1468) — neutering the helper turns 4/6 cases red. Gzipped bundle delta: 172 bytes.
  • url_change / dj-patch frames now stamp the consumer-owned wire version, ending the guaranteed VDOM version mismatch + forced reload (#1858, the #1788 parallel-path twin / #1646). url_change frames (from dj-patch clicks and browser popstate) are delegated to ViewRuntime.dispatch_url_change, which stamped the wire version directly from render_with_diff()'s return — the Rust render counter — and WSConsumerTransport.send forwarded it verbatim. The Rust counter is several renders ahead of the consumer counter on a real session (the HTTP-GET SSR pre-render + the WS-mount hydration re-render each advance it, while _next_version() counts only frames the consumer SENT), so the first url_change frame disagreed with the mount baseline, the client's clientVdomVersion === data.version - 1 check failed, and the client hit a non-recoverable error → forced page reload. dj-click worked (it stamps _next_version()); dj-patch did not — that asymmetry was the fingerprint. #1788 had unified the wire version onto the consumer counter for handle_mount / handle_event but not the ViewRuntime delegate paths. Fix: a new Transport.next_client_version(html, rust_version) hook is called by the runtime's render-send sites (dispatch_url_change and _render_and_send); WSConsumerTransport returns consumer._next_version_armed(html) — the same per-connection counter handle_event uses — which keeps the wire version monotonic with the mount baseline AND arms request_html recovery to that version (#1788 / #1817), while SSESessionTransport returns the Rust version unchanged (SSE never adopted the consumer counter — it is single-counter end-to-end and has no cross-counter drift; #1646 audit conclusion). Reproduce-first + gate-off verified (#1468) via real WebsocketCommunicator round-trips: 3 regression cases in python/djust/tests/test_url_change_wire_version_1858.py (test_url_change_stamps_consumer_version_not_rust_counter asserts the dj-click/dj-patch asymmetry + the [1, 2, 3, 4] no-collision chain, test_version_monotonic_across_click_then_urlchange_then_click pins the strict sequence across the url_change boundary, test_url_change_arms_recovery_to_its_own_version pins #1817 recovery arming) — reverting the WS hook to the Rust counter reproduces the [1, 2, 3, 3] collision + stale recovery version.
  • _get_project_app_dirs() no longer excludes project apps that live under a /djust/-named path (#1865). The shared check-discovery helper (python/djust/checks/utils.py) filtered out any app path that ended with "djust" OR contained the substring "/djust/". The filter's intent was to skip djust's OWN package dir so S009/S011 (and every other dir-walking check) don't lint the framework's own templates — but it was far too broad: a downstream project (or the repo itself) checked from INSIDE the djust repo tree lives under a …/djust/… path, so EVERY project app was dropped → _get_project_app_dirs() returned 0 dirs, S009 early-returned, and S011 saw only a fraction of templates. This blinded check dogfooding from within the repo (surfaced in PR #1864 review). The exclusion is now tightened to skip ONLY djust's actual package directory (os.path.realpath(os.path.dirname(djust.__file__))) or a directory inside it via the new _is_within_djust_package() helper — the framework's own templates stay excluded (intent preserved) while a consumer app that merely lives under a /djust/-named path is discovered. Verified against examples/demo_project from inside the repo: pre-fix discovery returned 0 app dirs, post-fix returns all 9 project apps + 10 template dirs with djust's package still excluded. New cases in TestGetProjectAppDirsDiscovery1865 (python/tests/test_checks_app_dir_discovery_1865.py) exercise the real helper (mocking apps.get_app_configs), reproduce-first + gate-off verified (#1468) — restoring the old /djust/-substring filter turns the two discovery tests red while the three intent-preserved exclusion tests stay green.

[1.0.7] - 2026-06-22

Security

  • Gated the OpenAPI schema endpoint against unauthenticated API-surface enumeration (F29 — CWE-200/CWE-651). OpenAPISchemaView (/djust/api/openapi.json) served the auto-generated OpenAPI 3.1 document to any anonymous client with no DEBUG or auth gate, handing out a complete machine-readable map of the expose_api attack surface: every endpoint URL, internal view-class + handler names, every parameter name/type, and handler docstrings. This was inconsistent with the framework's own posture — observability introspection is DEBUG+localhost-gated (F9) and API dispatch requires authentication, yet the schema describing those endpoints was wide open. The view is now secure-by-default: a new _openapi_gate(request) helper serves the schema only when (1) settings.DEBUG is True, (2) the new opt-in settings.DJUST_API_OPENAPI_PUBLIC is True (operator explicitly publishes the spec), or (3) the request is authenticated (request.user.is_authenticated); otherwise it returns a non-disclosing 404 (not 403, mirroring the observability gate so a gated client cannot confirm the endpoint exists). The auth check is fail-closed — a missing request.user (no AuthenticationMiddleware) or an anonymous user falls through to the 404. New setting: DJUST_API_OPENAPI_PUBLIC (default False) — set to True only if you intend the OpenAPI spec to be readable by unauthenticated clients. Default-behavior change (action required for some apps): with DEBUG=False and the setting unset, anonymous GET /djust/api/openapi.json now returns 404 instead of the schema; authenticated developers/integrators and DEBUG/dev environments are unaffected. Regression: new cases in TestOpenAPIGateF29python/djust/tests/test_openapi_gate_f29.py (anonymous default-deny non-disclosing 404, fail-closed missing-request.user, DEBUG-serves, opt-in-serves, authenticated-serves, helper-level precedence, and a gate-off self-test (#1468) proving the deny tests are non-tautological).

  • Multi-tenant isolation: the live (WebSocket) path now resolves the same tenant as the HTTP path for host/subdomain TenantResolvers (F26 — CWE-639/CWE-348). The WebSocket handle_mount and ViewRuntime._build_request reconstructed the request via RequestFactory().get(...) with no HTTP_HOST, so request.get_host() defaulted to "testserver" on the live path. Host/subdomain resolvers (e.g. SubdomainResolver, which reads request.get_host()) therefore misresolved the tenant to None over WebSocket — while the HTTP (SSR) initial render, using the real request, resolved the correct tenant. With STRICT_MODE=False the tenant-scoped managers then returned unscoped rows (cross-tenant disclosure) in WS event handlers; with the default STRICT_MODE=True they returned .none() (broken tenancy, and wrong/no tenant stamped on WS writes). The validated client Host (and the TLS scheme) is now propagated from the handshake scope into the reconstructed request through a single shared helper, djust.websocket.validated_host_from_scope, which validates the Host against settings.ALLOWED_HOSTS using the same logic as the CSWSH Origin gate — so host/subdomain resolution on the live path matches HTTP exactly: no weaker, no stronger than the HTTP layer. An absent or non-ALLOWED_HOSTS Host falls back to the prior default (non-browser clients keep working; a spoofed Host gains no tenant authority beyond HTTP). Covered by TestHttpWsTenantParity, TestAllowedHostsBound, TestNoHostFallback, TestSchemePropagation, TestMalformedHostRejected, and TestGateOff in python/djust/tests/test_ws_host_tenant_f26.py.

  • Unified the per-handler @rate_limit into one shared per-caller bucket across all three transports, and hardened the HTTP-API caller key against proxy collapse / XFF spoofing (F27 + F28 — CWE-770/CWE-799/CWE-400 + CWE-348). The @rate_limit(rate, burst) decorator is meant to throttle a caller's invocation rate of a specific handler (OTP/verification-email senders, expensive compute, brute-forceable actions). It was instead enforced against independent bucket stores that summed across two axes (parallel-path-drift, #1646). F27 (per-connection / per-transport multiplication): the WebSocket path enforced it via the per-connection ConnectionRateLimiter (websocket_utils._validate_event_securityrate_limiter.check_handler), so opening N WebSocket connections gave the configured limit (no concurrent-connection cap exists); SSE used a per-session limiter and the HTTP API a separate process-level dict, so a caller hitting the same handler over WS and the API consumed from both budgets independently. F28 (mis-keyed API limiter): api/dispatch._caller_key keyed unauthenticated callers by raw REMOTE_ADDR instead of the #5-hardened resolve_client_ip (which honors DJUST_TRUSTED_PROXY_COUNT and is already used by WS/SSE) — so behind a reverse proxy (standard prod topology) all unauthenticated callers collapsed to one ip:<proxy> bucket (ineffective per-client limiting + mutual DoS), and under a naive XFF→REMOTE_ADDR middleware the key became attacker-controlled (rate-limit bypass). Fixed once: a single process-level, LRU-capped (_HANDLER_BUCKET_CAP = 10_000) per-caller bucket store in djust.rate_limit (handler_rate_check(caller_key, handler_name, settings) over an OrderedDict of TokenBuckets keyed (caller_key, handler_name)) is now the sole enforcement point for the per-handler @rate_limit, used by all three transports. A shared caller_key(request, client_ip) mirrors the SSE owner-principal identity model (Findings #24/#25): user:<pk> when authenticated, else session:<session_key>, else ip:<resolved_ip> — and its IP fallback resolves through resolve_client_ip (closing F28: per-real-client buckets behind a trusted proxy, no XFF spoof). The WS/SSE/runtime chokepoint _validate_event_security and api/dispatch._rate_limit_check both route through the shared store; api/dispatch._caller_key now resolves the IP via resolve_client_ip and its misleading "REMOTE_ADDR should already reflect the caller via their proxy middleware" comment is removed. The GLOBAL per-message abuse-disconnect (#17) is untouchedConnectionRateLimiter.check/check_upload/should_disconnect (the connection-flood close(4429) + IP cooldown in websocket.py:receive) stays per-connection, which is correct for connection-flood control; only the per-handler @rate_limit is unified. Net invariant: a given caller has ONE @rate_limit budget per handler regardless of connection count or transport. Regression: 11 regression cases in python/djust/tests/test_ratelimit_per_caller_f27_f28.py (test_f27_* multi-connection/multi-context/cross-transport/two-WebsocketCommunicator end-to-end, test_f28_* trusted-proxy peel vs. peer-keyed XFF-ignored, test_global_* per-message abuse-disconnect still trips per connection, test_caller_key_precedence_*; reproduce-first + gate-off verified — reverting per-handler enforcement to the per-connection limiter makes the combined-count test allow 10 not 5, and reverting _caller_key to raw REMOTE_ADDR makes the F28 trusted-proxy test collapse both clients to the proxy bucket).

  • Consolidated and hardened LiveView mount-path resolution against unsafe reflection and SSE URL traversal (F22 + F23 — CWE-470/CWE-1188/CWE-209 + CWE-22). The client-supplied mount inputs (view dotted path and page url) are reached over three transports — WebSocket handle_mount, SSE _sse_mount_view, and the generic ViewRuntime.dispatch_mount/_instantiate_view — which had drifted: each carried its own copy of the view-import gate and only the WebSocket paths validated the mount URL. Both findings are now fixed once in a single shared module, djust.security.mount, that all three entry points call (parallel-path-drift cure, #1646). F22 (unsafe reflection): resolve_view_class(view_path) validates the dotted-path shape (module[.sub].ClassName of valid identifiers — rejecting .., leading/trailing dots, and bad characters), checks an allowlist before importing anything, imports via importlib.import_module + a vars(module).get(name) __dict__ lookup (never getattr, so a client class name cannot trigger a PEP 562 __getattr__ submodule import — GHSA-7prp-2623-8g45 follow-up), and only then runs the LiveView-subclass check as defense-in-depth. The allowlist now uses module-segment-boundary matching (path == entry or path.startswith(entry + ".")) instead of the old boundary-less startswith (which let ["myapp"] admit myapp_evil.views.Pwn). F23 (SSE traversal): the #1819/#1825 mount-URL validator (validate_mount_url, moved here verbatim; websocket._validate_mount_url is now a thin alias) is applied in ViewRuntime.dispatch_mount (and defensively in _build_request), so the SSE/runtime path neutralises /%2e%2e/admin/ identically to the WebSocket path — closing the traversal that had reopened on SSE because the validator ran only on the WS mount paths. Default-behavior change (action required for some apps): view resolution is now restricted to LIVEVIEW_ALLOWED_MODULES when that setting is configured; when it is unset/empty, the allowlist falls back to a non-breaking set derived from the project itself — the top-level package root of each INSTALLED_APPS entry plus "djust". This blocks arbitrary os/antigravity/site-packages imports out of the box (the prior default allowed any already-imported module, which os always defeats) without breaking the common case of an app that never set LIVEVIEW_ALLOWED_MODULES — legitimate LiveView classes live inside installed apps. Apps that mount a lazily-imported view living outside an installed app must now add its module to LIVEVIEW_ALLOWED_MODULES. Regression: 17 regression tests in python/djust/tests/test_mount_consolidation_f22_f23.py (TestF22ArbitraryImportRefused, TestF22Allowlist, TestF22Shape, TestF23Traversal, TestViewResolution — arbitrary-import-refused on WS + runtime paths, boundary match, INSTALLED_APPS-fallback non-breaking guard, shape rejection, WS↔runtime traversal parity; reproduce-first + gate-off verified).

  • Hardened the WebSocket transport against channel-layer mass assignment and an upload-frame rate-limit bypass (CWE-915/CWE-913 + CWE-770/CWE-400). Two defects in LiveViewConsumer. (1) server_push (the channel-layer handler for type:"server_push" messages, sent by push_to_view/apush_to_view) applied the message state dict via raw setattr(self.view_instance, key, value) in a loop — while the sibling handler field was already restricted against the framework's own stated threat model ("an attacker who gains access to the channel-layer backend", e.g. a shared Redis on a multi-tenant deployment). Raw setattr let such an attacker overwrite __class__ (type confusion), __init__, framework internals (_framework_attrs/_components/_rust_view), and arbitrary private _ state on a live view. Fixed by routing the loop through safe_setattr(..., allow_private=False) (djust.security.attribute_guard) — the same guard every other state-restore sink already uses (snapshot restore, time-travel, HTTP state restore) — which blocks dunders, the framework blocklist, and private names while still applying legitimate public state. (2) Binary upload frames (first byte 0x01/0x02/0x03, len ≥ 17) were dispatched to _handle_upload_frame and returned in receive() before the global per-connection rate-limit gate whose comment states it "applies to ALL message types (#107)" — so the highest-volume message class was unthrottled and never tripped the abuse-disconnect (close(4429) + IP cooldown), making an upload-frame flood (cheap, with a 1-in/1-out response amplification) the one frame type an attacker could send without ever being evicted. Fixed by routing binary upload frames through a dedicated, higher-ceiling upload token bucket on ConnectionRateLimiter (check_upload(); defaults upload_rate=200/s, upload_burst=400, configurable via LIVEVIEW_CONFIG['rate_limit']['upload_rate'|'upload_burst']) before dispatch — sized so a legitimate full single-file upload (~157 64 KB chunks for a 10 MB file) lands as a burst without throttling, while a sustained flood depletes the bucket, increments the shared warning counter, and trips should_disconnect()close(4429) + cooldown exactly like the text path. The per-frame 64 KB size cap is unchanged. (Out of scope, filed as follow-up: the per-dropped-chunk send_json response amplification / #824 stop-sending mechanism — the rate-accounting is the fix here.) Regression: test_ws_transport_hardening_f21_f17.py (TestServerPushStateMassAssignment, TestUploadFrameRateLimit, TestUploadBucketAccounting, TestReceiveRateGateParity; reproduce-first + gate-off verified for both findings).

  • Fixed CSRF on the SSE transport (CWE-352). The SSE client→server POST endpoints (DjustSSEEventView, DjustSSEMessageView) are @csrf_exempt and the SSE GET stream endpoint (DjustSSEStreamView) had no Origin check, so a cross-origin page could drive a victim-cookie-authenticated SSE session: force the victim's browser to GET /djust/sse/<attacker-uuid>/?view=... (which creates and mounts a LiveView as the victim via the victim's cookies) and then POST to /djust/sse/<attacker-uuid>/message/ with credentials: include to fire state-changing event handlers as the victim. The @csrf_exempt justification was false on three counts: the URL session_id is client-chosen (DjustSSEStreamView.get validates only its UUID format, never that the server issued it), so it is not a CSRF token; and a JSON body sent with Content-Type: text/plain is a CORS simple request accepted by the handlers (which json.loads the body regardless of content type) with no preflight — there was no Origin check at all (the WebSocket transport already has one). Fixed by mirroring the WS transport's CSWSH defense (#653): all three SSE endpoints now validate the request Origin against settings.ALLOWED_HOSTS (reusing djust.websocket._is_allowed_origin) and reject cross-origin requests with 403 before any session create/mount or event dispatch — a browser always sends Origin on a cross-origin request, so an attacker page's origin won't match; same-origin requests pass; non-browser clients (no Origin) still work. As defense-in-depth, the POST endpoints now require Content-Type: application/json (415 otherwise), closing the text/plain simple-request bypass and forcing a CORS preflight cross-origin. @csrf_exempt is retained but its docstrings are corrected to state the real CSRF defense is the Origin allowlist. Migration note: SSE client→server requests now require a same-origin Origin header (or no Origin, for non-browser clients) and Content-Type: application/json. The bundled djust client already sends both. Custom SSE clients that POST with text/plain or from a different origin must change to send application/json and a same-origin Origin (or no Origin for server-to-server / native clients). Regression: test_sse_csrf_origin.py (11 cases: foreign-origin GET/POST → 403 with no session/dispatch, same-origin → not 403, missing Origin → allowed, text/plain → 415, application/json → accepted; reproduce-first + gate-off verified).

  • Fixed multi-tenant isolation failing open on the WebSocket/SSE path (CWE-862 / CWE-636). djust.tenants isolation was enforced only on the HTTP path. The current tenant was stored in threading.local() and set exclusively by TenantMiddleware (HTTP-only), so on the live (WebSocket/SSE) path get_current_tenant() was always None during mount + every event handler — and the tenant-aware managers failed OPEN: TenantQuerySet._filter_by_tenant returned the unfiltered queryset and never consulted STRICT_MODE, disclosing every tenant's rows to whoever held the socket. TenantQuerySet additionally RecursionError'd whenever a tenant was set (its _chain override re-entered filter()_chain → ...), and Model.objects.all() was unfiltered even with a tenant bound. Fixed four ways: (A) tenant storage is now a contextvars.ContextVar (per async-task), not threading.local()threading.local is shared across all connections on asgiref's thread_sensitive sync_to_async executor thread (a cross-tenant clobber), whereas ContextVar is copied per-call into the executor so each connection stays isolated; the public API (get_current_tenant/set_current_tenant) is preserved and a new tenant_context() context manager is added for drift-free set/clear. (B) The live path now binds the resolved tenant into the ContextVar around WS/SSE mount and every event/url-change dispatch, cleared after (WS handle_mount/handle_event/disconnect, ViewRuntime.dispatch_{mount,event,url_change}, and the legacy _sse_mount_view/_sse_handle_event). (C) Both tenant managers now scope the base queryset once in a shared get_queryset() helper (no recursion; .all() is scoped too) and fail CLOSED — with no tenant bound they return .none() under STRICT_MODE (the default), matching TenantManager; unfiltered only when STRICT_MODE is explicitly False. (D) New system check S006 warns when DJUST_TENANTS['STRICT_MODE'] is explicitly False (disables fail-closed isolation; risks cross-tenant disclosure). Migration note: storage moved from threading.localContextVar (transparent — same public API). Tenant managers are now fail-closed by default: a tenant-scoped query that runs with no tenant in context now returns an empty queryset instead of all rows. Apps that relied on the old fail-open behaviour must either keep a tenant bound on every query path, use Model.objects.unscoped(reason=...) for deliberate cross-tenant reads, or set DJUST_TENANTS['STRICT_MODE'] = False — which is now flagged as dangerous by S006. Regression: test_tenant_isolation_contextvar.py (16 cases covering both manager variants, fail-closed/lax/recursion/.all()-scoping, ContextVar isolation across interleaved async contexts, live-path set/clear, and S006).

  • Fixed unauthenticated arbitrary module import via the WebSocket/SSE view-mount path (GHSA-7prp-2623-8g45, CWE-470). The live transport resolved the LiveView to mount from a client-supplied dotted path via __import__(module_path) — executing that module's top-level code — before the LiveView-subclass check and per-view auth, and the LIVEVIEW_ALLOWED_MODULES guard was fail-open (skipped when unset, the default) with loose startswith matching. An unauthenticated client could cause the server to import (and run the import-time side effects of) any importable module by name. Fixed with a fail-closed gate (djust._view_resolution.is_view_import_allowed) applied before __import__ at all three sinks (handle_mount, ViewRuntime.dispatch_mount/_instantiate_view, SSE): a client view path resolves only if its module is already loaded (so resolving runs no new code — URL-routed views keep working with zero config) or it matches LIVEVIEW_ALLOWED_MODULES on a module-segment boundary (no longer startswith). The class is resolved via importlib.import_module (no fromlist) + vars(module).get(class_name) so a client-controlled class name cannot trigger a module-level __getattr__ (PEP 562) submodule import. Regression: test_security_view_import_failclosed.py.

  • Enforced view-level authorization on the WebSocket/SSE mount path (CWE-862). The live transport authorizes a mount via check_view_auth, not Django's View.dispatch() chain — so standard Django authorization (LoginRequiredMixin, UserPassesTestMixin, @method_decorator(login_required, name="dispatch"), custom dispatch() guards) and djust's own admin extension (gated only by the HTTP as_view wrapper) were enforced on the initial HTTP GET but silently bypassed over WebSocket, where all events and state flow. An anonymous/under-privileged client could open a WebSocket and mount such a view. Fixed three ways: (1) check_view_auth now honors the Django AccessMixin family (LoginRequiredMixin / PermissionRequiredMixin / UserPassesTestMixin), mirroring handle_no_permission() semantics and leaving the view's request attribute unchanged; (2) a new system check S004 (djust.S004) fails loud at startup on the auth patterns the runtime cannot safely re-run in the WS context — a LiveView subclass with @method_decorator(<auth>, name="dispatch") or an overridden dispatch() that performs authorization itself — pointing the developer at the djust login_required/permission_required/check_permissions attributes or a supported mixin; (3) admin_ext.AdminBaseMixin now declares login_required = True + a check_permissions active-staff gate so the admin extension is staff-gated on every transport. The AccessMixin family is honored automatically; the decorator/overridden-dispatch forms are surfaced by S004 rather than auto-honored (they're HTTP-only and cannot be replayed without producing an HttpResponse). Regression + detection-canary tests in test_ws_django_auth_bypass.py.

  • Enforced object-permission (ADR-017) on every render path (CWE-862 / IDOR). The post-mount object-permission check (get_object + has_object_permission) was enforced on the WebSocket mount + event paths but not on the initial HTTP GET render (RequestMixin.get/aget), SPA url_change navigation (ViewRuntime.dispatch_url_change), or {% live_render %} embedded children — so an object-scoped view rendered a denied object on those paths. A shared chokepoint djust.auth.core.enforce_object_permission (no-op for views without a custom get_object; raises PermissionDenied on denial; fail-closed on a None request or any non-PermissionDenied exception) is now called from all three: the HTTP render returns 403, url_change sends a permission_denied error frame and skips the render, and {% live_render %} (eager + lazy) refuses the embed. Object-level authorization is now uniform across every mount/render entry point. Regression: test_object_perm_render_paths.py.

  • Resolve the client IP from the socket peer, not a spoofable X-Forwarded-For (CWE-348). The WebSocket (_get_client_ip) and SSE (_client_ip_from_request) paths took the leftmost X-Forwarded-For value unconditionally and used it for per-IP connection limiting + cooldown/ban — a client-controlled identity. An attacker could rotate X-Forwarded-For to bypass the per-IP cap entirely, or spoof a victim's IP to drive it into cooldown and lock legitimate users out. Both transports now resolve the IP via the shared djust._client_ip.resolve_client_ip: by default the real socket peer (REMOTE_ADDR / ASGI scope["client"]) is used and X-Forwarded-For is ignored; behind a trusted reverse proxy set the new DJUST_TRUSTED_PROXY_COUNT = N setting and the client is taken as the Nth entry from the right of the chain (peeling N trusted hops; the spoofable left side is never trusted), falling back to the peer if the chain is shorter than N. Regression: test_client_ip_trusted_proxy.py.

  • Validate the URL scheme in built-in component href/action sinks (CWE-79). Built-in component tags rendered a developer/user-supplied URL into an href/action attribute with conditional_escape (HTML-entity escaping) but no scheme validation — so a javascript: URI (which contains no escapable characters) landed verbatim and executed on click. A new djust.components.templatetags._registry.safe_url helper HTML-escapes and neutralizes dangerous schemes (javascript:/vbscript:/data:, including control-char/whitespace-obfuscated variants) to #, while preserving http(s)/mailto/tel/ftp and relative/anchor/query URLs. Applied at all 11 navigation-context URL sinks across djust_components.py (breadcrumb, dj_nav link/dropdown/brand, citation, cookie-consent), _advanced.py (error-page action, advanced breadcrumb ×2), and _forms.py (form action). <img src> thumbnail sinks and a non-navigation status-text interpolation are intentionally excluded (javascript: doesn't execute via <img src>; data: images are legitimate; status text is not an href/action context). Regression: test_component_safe_url.py.

  • Script-safe JSON encoding for inline <script> JSON sinks (CWE-79). json.dumps does not escape <, >, or &, so interpolating its output directly into an inline <script> block let a string value containing </script> close the element and inject markup. Two sinks were affected: the DEBUG-only debug-panel injection (post_processing._inject_client_script, where get_debug_info() includes repr()s of user-controlled public view attributes) and js.JSChain.__html__ (whose docstring also falsely claimed JSON escapes </>). A new djust.security.escape_json_for_script helper translates <, >, & (and U+2028/U+2029) to \uXXXX — matching django.utils.html.json_script — and both sinks now route through it. Regression: test_debug_json_script_escape.py.

  • Restricted the default update_model (dj-model) handler to template-bound fields (CWE-915 mass assignment). ModelBindingMixin is in the LiveView base MRO, so every LiveView exposes a default @event_handler update_model(field, value) that setattrs a view attribute whose name is client-supplied. The only gates were: reject _-prefixed names, reject the 14-entry FORBIDDEN_MODEL_FIELDS denylist, optionally require membership in allowed_model_fields (which defaulted to None = allow ALL public attrs), and require hasattr. So a client could set any public, existing view attribute — is_admin, account_id, total_price, … — not just the fields actually bound with dj-model="…" in the template, an IDOR / authz-flag / price-tampering surface on the standard djust state pattern. Fixed with a secure-by-default auto-allowlist derived from the TEMPLATE SOURCE: the Rust template engine walks the parsed template AST and collects every static dj-model="<field>" binding from Node::Text literals (covering {% extends %} and {% include %}), exposed to Python as RustLiveView.dj_model_fields() (and a module-level dj_model_fields_from_template(source, dirs) for embedded children). This is recorded on self._dj_model_fields on every render via ModelBindingMixin._record_dj_model_fields_from_rust (LiveView paths) / _record_dj_model_fields_from_source (embedded children). Deriving the set from the template source — not the rendered HTML — is the load-bearing security property: the source is developer-authored text that attacker data can never reach (it flows only through {{ }} Node::Variable substitution at render time), so the three rendered-output poisoning vectors that defeated an earlier rendered-HTML approach (attacker text nodes, unquoted-interpolated attributes <div x={{ v }}>, and |safe content carrying <input dj-model=…>) cannot widen the allowlist. update_model is fail-closed — a field is bindable iff it is in self._dj_model_fields (auto-allowlist) OR in an explicit allowed_model_fields (union semantics); the _-prefix / FORBIDDEN_MODEL_FIELDS / hasattr checks remain as defense-in-depth. Collection runs at all three full-HTML render chokepoints (render, _render_full_template_inner, render_with_diff) plus the embedded-child render paths (websocket._render_embedded_child, live_render) via a single shared helper to avoid parallel-path drift; render_with_diff is the dominant one (HTTP-GET baseline + every WS mount + every WS event). _dj_model_fields is assigned before the _framework_attrs snapshot so it is a framework slot (recomputed each render, never persisted). Migration note: the auto-allowlist now covers static dj-model= bindings in templates (including {% extends %}/{% include %}). A dynamic binding dj-model="{{ var }}" (resolved at render time) and any field written purely programmatically are not auto-allowed — add those names to allowed_model_fields. Existing static dj-model="x" bindings keep working with zero config. Regression: test_update_model_allowlist.py (the three poisoning vectors verified end-to-end through render_with_diff, reproduce-first + gate-off verified, plus a real-render integration test) and Rust unit tests in crates/djust_templates/src/parser.rs.

  • Signed state snapshots — rejected unsigned/forged client snapshots on the back-navigation restore path (CWE-345 → CWE-915). The opt-in state-snapshot feature (LiveView.enable_state_snapshot = True) restored a view's public state from a client-supplied snapshot on the live_redirect_mount back-navigation path, in lieu of calling mount(). The snapshot was embedded in the page and round-tripped through the client UNSIGNED: the server sent the public state, the client re-serialized it (JSON.stringify) and echoed it back, and the server trusted it — LiveView._restore_snapshot safe_setattrs every public key from the client's state_json. Because the payload carried no authenticity proof, a client could forge an arbitrary snapshot (e.g. {"is_admin": true, "account_id": 7}) and inject arbitrary public state, a state-injection / mass-assignment vulnerability gated only by safe_setattr's attribute-name regex (which permits ordinary public names). Fixed by signing the snapshot server-side with Django's TimestampSigner (keyed on SECRET_KEY, salt "djust.state_snapshot") in a new shared helper djust.security.state_snapshot (sign_snapshot / unsign_snapshot — single source of truth for both the emit and restore halves, no parallel-path drift). The mount frame now carries an opaque signed blob (state_snapshot_signed) instead of the plaintext public_state dict; the client stores it verbatim and echoes it back unchanged (the client no longer re-serializes — re-serializing would strip the signature). On restore the server runs the inbound blob through unsign_snapshot before applying any state, verifying: the HMAC signature (rejecting unsigned/forged/tampered payloads), a configurable TTL (DJUST_STATE_SNAPSHOT_MAX_AGE, default 3600 s — rejecting expired snapshots), and an identity binding to the view slug + Django session key (rejecting cross-view and cross-session replay). Any rejection drops the snapshot and falls back to a normal mount() with mounted_from_snapshot = False. The existing 64 KB size cap, 256-key keyset cap, dict-type check, slug-match, and per-view opt-in gates are retained as defense-in-depth, now applied to the verified inner JSON. There is no bypass via the legacy plaintext state_json — unsigned input fails the signature check. Migration note: state snapshots are now HMAC-signed (TimestampSigner/SECRET_KEY) with a TTL; unsigned, forged, tampered, expired, or cross-view/cross-session snapshots are rejected and the view falls back to mount(). The bundled djust client handles the new opaque blob automatically. Any custom client or any view that overrides _capture_snapshot_state / _restore_snapshot must round-trip the signed blob verbatim (do not re-serialize it) — the single field name state_json still carries the blob end-to-end. Configure the TTL via DJUST_STATE_SNAPSHOT_MAX_AGE (seconds, default 3600). Regression: python/djust/tests/test_state_snapshot_signing.py (real WebsocketCommunicator forge-rejection + signed round-trip, plus tamper / expiry / cross-view / cross-session / anonymous; reproduce-first + gate-off verified) and tests/js/state_snapshot_signed.test.js (client stores + echoes the opaque blob verbatim, no re-serialize).

  • Enforced localhost in-view on the observability endpoints + restricted eval_handler (CWE-668 / CWE-306). The _djust/observability/ endpoints (live cross-session state, tracebacks, logs, and the eval_handler method-invocation endpoint) gated on settings.DEBUG in every view, but the localhost check lived only in the opt-in LocalhostOnlyObservabilityMiddleware — omitted from the documented setup and not auto-installed — so with DEBUG=True and the middleware absent (e.g. a 0.0.0.0-bound staging server) they were reachable from any host. Localhost is now enforced in every view (observability.views._gate, returning a non-disclosing 404), so the boundary holds regardless of middleware; eval_handler now only invokes @event_handler-decorated methods (the same allowlist the WebSocket event path uses), not arbitrary public methods; a new system check A031 warns when the observability URLs are wired without the middleware; and the setup docs now show the MIDDLEWARE entry. Regression: test_observability_localhost_gate.py.

  • Added a secure-by-default sensitive-field denylist to Django model serialization (CWE-200 / CWE-359). DjangoJSONEncoder._serialize_model_safely serialized every concrete field of a Django Model, so assigning a Model with sensitive fields to a public LiveView attribute (the natural self.user = request.user pattern that djust's own _private/public convention encourages) sent fields such as the password hash and the is_superuser/is_staff privilege flags to the browser — across all client-bound serialization paths (the JIT full-dump fallback, the opt-in state snapshot, and get_state()). Fixed with a denylist applied inside _serialize_model_safely (and its @property / get_* additions), so it covers every path: the built-in floor always drops password, is_superuser, and is_staff; settings.DJUST_SENSITIVE_FIELDS (any iterable) unions project-wide additions on top of the floor; a per-model djust_exclude_fields iterable drops additional fields; a per-model djust_serializable_fields allowlist, when present, restricts output to exactly those fields plus the identity keys (pk/id/__str__/__model__) and can opt a floored field back in; and a model-level to_dict() is the full opt-out (the developer takes ownership of the client-bound payload). Setting resolution is defensive — a missing setting or unconfigured Django degrades to the built-in floor and never raises during serialization. As defense-in-depth, the JIT serializer's empty-paths fallback (a whole-object {{ user }} reference, or a public attr never field-referenced in the template) now emits only the identity subset rather than a full field dump — least-exposure, since no field of the object is actually referenced. Template-referenced serialization is unchanged (it flows through the compiled JIT serializer, which already emits only referenced paths). Migration note: password, is_superuser, and is_staff are no longer serialized for any Model by default; add field names to settings.DJUST_SENSITIVE_FIELDS or per-model djust_exclude_fields to drop more, use a per-model djust_serializable_fields allowlist to restrict to an explicit set, or define a model to_dict() to fully control the payload. Regression: python/djust/tests/test_serializer_field_exposure_f19.py — classes TestSensitiveFieldDenylist, TestSettingsOverride, TestPerModelControls, TestToDictOverride, TestJitEmptyPathsFallback, and the gate-off sentinel TestGateOff (reproduce-first + gate-off verified).

  • Added a path-safe accessor for the upload original filename and stopped teaching the raw filename in storage paths (CWE-22 / CWE-73). UploadEntry.client_name is the raw, attacker-controlled original filename, and the UploadEntry/upload-mixin usage docstring taught interpolating it directly into a storage destination key — default_storage.save(f'avatars/{entry.client_name}', entry.file) — a path / object-key injection sink. On FileSystemStorage a value like ../../../etc/x raises SuspiciousFileOperation (500 / DoS); on object stores (S3/GCS/Azure) ../ is a valid key, so the attacker controls the destination object key (overwrite / mis-place of arbitrary objects). The framework already knew the field was dangerous (the internal S3 chunk-writer sanitizes the same value, and system check S007 flags {{ ...client_name|safe }} as a stored-XSS sink), but the developer-facing field had no safe accessor and the docstring taught the unsafe use. Fixed by adding a sanitized property UploadEntry.safe_client_name — Unicode-normalises (NFKC) compatibility lookalikes first (so a fullwidth solidus or fullwidth full stop can't survive as a latent ../../ traversal that a downstream normaliser re-expands), drops every Unicode control/format char (NUL, C0/C1 controls, zero-width, and U+202E bidi-override Trojan-source spoofing — not just ASCII), basename only (strips directory components, including backslash-separated Windows paths), strips leading dots so the result can't become .././a dotfile, strips trailing dots/spaces (Windows strips these at the FS layer, so evil.png. would otherwise collide with evil.png), and falls back to "upload" if nothing safe remains — mirroring the intent of Django's Storage.get_valid_name / werkzeug's secure_filename while preserving ordinary names (my report (1).png stays readable). The class docstring now uses safe_client_name in the default_storage.save(...) example and documents client_name as the raw injection-prone field. client_name itself is unchanged (developers still need the original for display / Content-Disposition). A new Python-AST system check S008 (the path-sink sibling of the template-side S007) flags raw client_name interpolated into a storage .save(...) / os.path.join(...) path, ignoring safe_client_name and plain display interpolation (zero false positives against the demo project). Migration note: use entry.safe_client_name (not entry.client_name) when building any storage path or object-store key; client_name remains the raw original filename for display only and must go through HTML auto-escaping (or escape()) — never |safe (S007). Regression: python/djust/tests/test_upload_safe_client_name.py (33 cases incl. the documented-path-use reproduce, Unicode-lookalike / trailing-dot / bidi-override hardening, a gate-off invariant, and an S008 empirical canary).

  • Rejected browser-executable "active content" uploads by default (CWE-79 / CWE-434). The upload content-validation chain allowlisted SVG as a benign image (MAGIC_BYTES["image/svg+xml"] + EXT_TO_MIME[".svg"]), and validate_magic_bytes is permissive for any MIME it has no signature for. So a script-bearing SVG (<svg onload=…><script>…</script>) — or an HTML/JS payload — passed every check, including the magic-byte step, which actively confirmed it as a valid image. Combined with the common accept="image/*" slot and inline serving, this is a stored-XSS / dangerous-file-upload vector, made worse because the framework's validation gave developers false assurance. Fixed with a fail-closed active-content denylist that is independent of the accept/image/* wildcard: UploadManager.register_entry (primary gate) and UploadManager.complete_upload (finalize defense-in-depth, so a chunked upload can't bypass via a path that skips register) now reject any upload whose declared MIME is in _ACTIVE_CONTENT_MIMES (image/svg+xml, text/html, application/xhtml+xml, application/javascript, text/javascript) or whose filename extension is in _ACTIVE_CONTENT_EXTENSIONS (.svg, .svgz, .html, .htm, .xhtml, .js) — matching on either axis because the attacker controls both. The check is the single public metadata-only helper djust.uploads.is_active_content(client_name, client_type), used by both gates (so it also covers the writer and disk-buffer finalize variants), and it normalises both inputs before the membership check so neither axis can be bypassed: the declared MIME has its parameters stripped (image/svg+xml; charset=utf-8 and image/svg+xml; are matched as image/svg+xml, since browsers ignore the parameter when choosing the renderer), and the filename is run through the same _safe_basename canonicaliser that UploadEntry.safe_client_name uses (extracted into one shared module-level helper) before its suffix is taken — so the gate inspects exactly the basename the storage layer will persist. This closes a self-inconsistent bypass where a trailing-space/dot name (evil.svg / evil.svg.) made the raw Path(...).suffix miss (.svg .svg) while the storage normaliser still wrote it to disk as evil.svg. validate_magic_bytes keeps its permissive-for-unknown default so legitimate txt/csv/json/png/jpg/pdf uploads are unaffected (including with MIME parameters, e.g. text/csv; charset=utf-8) — its docstring now notes it is best-effort format validation and that active content is gated separately. Migration note: uploads of SVG/SVGZ/HTML/XHTML/JS (by MIME or extension, including parameterised MIME types and trailing-dot/space filenames) are now rejected by default, even under an accept="image/*" slot. To accept them, set allow_upload(..., allow_active_content=True) on the slot — at which point you take ownership of the risk: djust does not sanitize the content; sanitize it yourself and/or serve it with a hardened Content-Security-Policy, X-Content-Type-Options: nosniff, and Content-Disposition: attachment (ideally from a separate origin) so a malicious upload cannot execute in the app's origin. Regression: python/djust/tests/test_upload_active_content_f20.py — classes TestActiveContentRejectedByDefault, TestActiveContentOptIn, TestBenignUploadsStillAccepted, TestFinalizeDefenseInDepth, TestMimeParameterBypass, TestFilenameCanonicalisationBypass, TestSvgzExtension, TestGateAgreesWithSafeClientName, TestSafeBasenameSharedHelper, TestIsActiveContentHelper, and the gate-off sentinel TestGateOffSentinel (reproduce-first + gate-off verified: neutering is_active_content makes 25 security cases fail).

  • Escaped + DEBUG-gated the embedded-child render-error path (CWE-79 / CWE-209). LiveViewConsumer._render_embedded_child rolled its own error string in its except block and returned it as the embedded child's subtree HTML (delivered to the client as an embedded_child full-HTML update), bypassing the framework's central, DEBUG-gated create_safe_error_response path. The returned <!-- Error rendering embedded child: {e} --> interpolated the raw exception message unescaped and was not DEBUG-gated, so (a) the exception detail leaked into the live production page (CWE-209) and (b) a message carrying attacker-influenced data containing --> (common in built-in exceptions that echo the offending value, e.g. int()/float()/KeyError) broke out of the HTML comment into live DOM (CWE-79 DOM XSS). Fixed by routing the detail through django.utils.html.escape (neutralising the comment-breakout and any tag injection in both modes — --> becomes --&gt;, <script>/<img become inert entities) and gating it on settings.DEBUG: production now emits a detail-free <!-- Error rendering embedded child -->, mirroring simple_live_view.render_template's DEBUG gate. As defense-in-depth, the {% live_input %} unknown-field_type error comment in templatetags/live_tags.py (a template-author literal, not attacker input) was switched from mark_safe(f"... {field_type!r} ...") to format_html, so the interpolated value is escaped and can't break out of the comment regardless of source. Regression: python/djust/tests/test_embedded_child_error_escape_f18.py (TestEmbeddedChildErrorEscapeF18 — no comment-breakout / no live tags in both DEBUG and prod, prod leaks no exception detail; reproduce-first + gate-off verified).

  • Validated the scheme/origin of server- and data-derived client navigation targets at every window.location.href sink (CWE-601 / CWE-79). The client applied navigation/redirect targets pushed over the wire by assigning window.location.href, but the guard was applied inconsistently across transports (parallel-path drift). The WebSocket navigate handler had an inline same-origin guard, while the SSE navigate handler (static/djust/src/03b-sse.js) assigned window.location.href = data.to raw, and the shared live_patch/live_redirect cross-origin fallbacks (static/djust/src/18-navigation.js) — added in #1599 to support legitimate absolute sister-site URLs — performed an origin check (new URL(path, origin).origin !== location.origin) but no scheme check. A javascript: or data: target parses to an opaque origin ("null"), which is !== location.origin, so it passed the cross-origin routing test and was assigned straight to window.location.href, where the browser executes it — an open-redirect (CWE-601) and javascript:/data: DOM-XSS (CWE-79). A natural developer redirect-after-action pattern (self.live_redirect(request.GET.get("next"))) flows attacker-controlled input into the sink. Retired the class structurally (#1646) rather than copying the WS inline guard to N sites: a single shared helper window.djust.safeNavigationTarget(value) (new static/djust/src/02b-safe-nav.js) returns a sanitized string or null — for same-origin absolute paths (/foo, /foo?x=1#h) it re-resolves the candidate through new URL(value, window.location.origin) and accepts ONLY the canonicalized pathname+search+hash if it genuinely resolves same-origin (a raw charAt(1) !== '/' prefix check is NOT enough: the WHATWG URL parser normalizes \/ and strips ASCII tab/newline, so /\evil.com, /\/evil.com, /\t/evil, /\n//evil all resolve cross-origin despite starting /x — these are now rejected), accepts absolute http:/https: URLs (the legitimate #1599 sister-site case), and rejects javascript:/data:/vbscript:/blob:/file: schemes, opaque-origin results, protocol-relative (//evil.com) and backslash/control-char off-origin tricks, and unparseable/empty input (warning under globalThis.djustDebug). It is now applied at every location.href navigation sink: the SSE navigate handler, the WS navigate/nav.to fallback (replacing the inline same-origin guard so the WS and SSE paths share one implementation and cannot drift apart again), the handleLivePatch/handleLiveRedirect cross-origin fallbacks, and the unresolved-view, dj-patch-reload, and auto-navigate-link fallbacks. As belt-and-suspenders, developers should not pass unvalidated user input to live_redirect(path=...)/live_patch(path=...); the client guard now neutralizes the worst case regardless. Regression: tests/js/safe_nav.test.js (33 cases — the helper across same-origin/http(s)///evil/javascript:/data:/vbscript:/blob:/file:/empty/garbage, plus a backslash/control-char open-redirect battery (/\evil.com, /\/evil.com, /\\evil.com, /\t/evil, /\n//evil all rejected) and a query+hash-preserved accept case proving the re-resolve path; the SSE and live_patch/live_redirect sinks driven with both safe and javascript:/data:///evil/backslash targets; a WS↔SSE same-helper parity pin; and a gate-off proof that removing the guard fails 23 rejection tests, #1468).

  • Bound SSE sessions to their owner and capped SSE session creation (CWE-639 / CWE-862 / CWE-770 / CWE-400). The SSE transport dropped two authorization/abuse controls the WebSocket transport already has. (1) No user-binding (session hijack / missing authorization). DjustSSEStreamView.get created an SSESession keyed by a client-chosen session_id and stored only the client IP, while DjustSSEEventView.post and DjustSSEMessageView.post dispatched on it with no check that the POSTer owns the session — handlers ran with the mounter's captured request.user. So anyone who learned a leaked session_id (URLs routinely leak via access logs, referrers, APM traces) could drive a victim-authenticated SSE view and perform state-changing actions as the victim, without the victim's cookie. The WS transport binds every event to the authenticated connection; SSE dropped that binding. (2) Unbounded session creation (unauthenticated DoS). The stream GET registered the session in the process-global _sse_sessions before mounting, never removed it on mount failure, and had no per-client/global cap and no rate-limit — so a scripted client could allocate unbounded long-lived sessions (each a registered session + queue + view), and sessions accumulated even for auth-required/failed mounts. Fixed by (a) owner-binding: the stream GET captures the creating principal on the session (_owner_user_pk for an authenticated mounter; a Django session key for an anonymous mounter, forced non-None via request.session.save() before reading so anonymous sessions are tied to the browser session cookie), and both POST endpoints re-verify ownership through one shared _request_owns_session(request, session) helper (authenticated → match user pk; anonymous → match session key) before dispatch, returning 403 with no dispatch on mismatch — a single helper used by both endpoints so the two paths cannot drift (#1646); and (b) resource caps: a per-principal/IP concurrency cap (429) keyed by the same owner identity and a global cap on len(_sse_sessions) (503), both module constants overridable via DJUST_SSE_MAX_SESSIONS_PER_CLIENT (default 20) / DJUST_SSE_MAX_SESSIONS_TOTAL (default 10000) and checked before any allocation, plus register-after-mount — the session enters _sse_sessions only after a successful mount, so a failed/unauthorized/redirecting mount leaves no live POST-routable session (the stream still delivers the queued error/navigate frame, then closes). Regression: python/djust/tests/test_sse_session_binding_f24_f25.py (TestF24OwnerBinding, TestF24OwnerCapture, TestF25Caps — cross-user/cross-anonymous POST → 403 + no dispatch, same-user/same-session allowed, GET owner capture + forced anon session key, per-client 429 / global 503 / failed- and unauthorized-mount leave no registered session; reproduce-first + gate-off verified per #1468).

  • Locked the consolidated mount-path security (F22 + F23, above) against parallel-path-drift regression with a transport-parity + chokepoint-structural net (#1646). No production behavior change — this adds the enforcement net that makes the "one transport has a security control the other lacks" mount-path drift class mechanically detectable. See the matching ### Added entry.

Added

  • Added a transport-parity + chokepoint-structural regression net so the WS↔SSE↔runtime "one transport has a control the other lacks" mount-path drift class cannot silently recur (#1646 enforcement half). Two new test modules pin the mount-path consolidation in place. python/djust/tests/test_transport_parity_security.py (TestImportAllowlistParity, TestMountUrlTraversalParity) parametrizes the same attacker payload over every mount entry point — WebSocket handle_mount, generic ViewRuntime.dispatch_mount/_instantiate_view, and SSE _sse_mount_view — and asserts an identical security verdict on each: arbitrary-module view paths (os.system, antigravity.X, subprocess.Popen, plus a side-effecting sentinel module) are rejected without importing on all three; a legit INSTALLED_APPS LiveView mounts on all three; and a mount-URL traversal (/%2e%2e/%2e%2e/admin/, /../../admin/) is neutralised to / with /items/42/ preserved on every request-building transport, which must agree. python/djust/tests/test_mount_chokepoint_structural.py (TestDynamicImportChokepoint, TestSetattrChokepoint, TestFactoryGetChokepoint) is an AST scan of the top-level python/djust/*.py modules with an explicit, comment-justified expected-site allowlist: it fails if a NEW unsanctioned import_module(view_path)/__import__(<non-literal>), setattr(view, <non-literal key>, …), or factory.get(<client URL>) (outside a validate_mount_url-calling function) site appears. Adding a 4th transport is one adapter-registry entry; a regression on an existing one fails a parametrized case loudly. Both suites are non-tautological (gate-off verified per #1468): bypassing the shared resolver/validator on any one transport, or injecting a dummy unsanctioned import/setattr/factory.get site, makes the corresponding case FAIL.

[1.0.7rc1] - 2026-06-18

Added

  • System check T017 — warn on dj-view / dj-root on a table-section element (#1837). A new template static-analysis check (severity WARNING) flags a dj-view or dj-root attribute placed on an HTML table-section element (<tbody>, <thead>, <tfoot>, <tr>, <td>, <th>, <caption>, <col>, <colgroup>). Such a view renders to silent garbage: html5ever foster-parents the table elements out of the tree at render time, so <tbody dj-view="…">{% for %}<tr>…{% endfor %}</tbody> renders as <html><head></head><body>text</body></html> (all rows dropped) with NO error. The matcher is same-tag-scoped (a <div dj-view> wrapping a <table><tbody> does NOT false-match — the attribute must be on the table-section opening tag itself), word-boundaries the tag name so <trx / <tablefoo don't match, and tolerates attribute order / whitespace / case. The fix hint points the developer to a wrapping element (the <table> or a surrounding <div>). Honors DJUST_CONFIG['suppress_checks'] for both the short (T017) and fully-qualified (djust.T017) id forms. Covered by TestT017TableSectionRegex and TestT017CheckIntegration in python/tests/test_checks_t017_table_section_1837.py (regex word-boundary / attribute-order cases, fires/no-false-positive empirical canary per #1459, and suppression).

Fixed

  • WebSocket: recovery version no longer goes stale across non-arming render-send paths (#1817). After #1816 (#1788) every client-checked outbound frame stamps the consumer-owned _last_sent_version, and _arm_recovery captures _recovery_version = _last_sent_version at arm time; handle_request_html sends the html_recovery frame stamped with that _recovery_version. But several render-send paths advanced the wire version WITHOUT arming recovery — the async-result error arms, the deferred-activity render, the hotreload frame, the time-travel jumps (handle_time_travel_jump / handle_time_travel_component_jump / handle_forward_replay), and the tick / db_notify broadcasts. After such a frame the client's applied version was ahead of _recovery_version, so a later request_html returned an html_recovery stamped with the stale _recovery_version — the client reset clientVdomVersion backwards and the next successful diff's data.version - 1 no longer matched, forcing an extra recovery round-trip (severity low, post-#1785: an extra round-trip, not data loss). Structural cure (#1646 parallel-path discipline): one helper, _next_version_armed(html), advances the wire version AND arms recovery in a single step, and every render-send path is routed through it; non-render baselines (mount) stay on bare _next_version(). The actor event path is left on bare _next_version() pending follow-up (its result['html'] is not the pre-strip render the recovery path expects). New end-to-end regression test test_time_travel_jump_recovery_version_is_current (a real WebsocketCommunicator round-trip) plus helper-level unit tests in python/djust/tests/test_recovery_version_staleness_1817.py; the _next_version_armed render-send call-site count is pinned by test_every_client_checked_send_path_uses_next_version in python/djust/tests/test_ws_send_version_1788.py, and the consolidated single _arm_recovery call site is pinned by test_arm_recovery_call_site_count_matches_known_send_paths in tests/unit/test_arm_recovery_helper_1645.py.

[1.0.6] - 2026-06-18

Stable release consolidating 1.0.6rc1–rc3. Headline: two P0 VDOM data-loss fixes for {% if %} inside {% for %} (#1826 relative-position move decision, #1832 unique per-iteration marker ids), a new stored-XSS system check (S007), WebSocket URL validation, the consumer-owned VDOM send-version, the checks.py package modularization, and a transitive-dependency security sweep.

Added

  • System check S007 — unsafe client_name|safe stored-XSS detection (#1821). A new template static-analysis check (severity WARNING) flags {{ <expr>.client_name|safe }} patterns in template files. An upload entry's client_name is the attacker-controlled original filename, stored without sanitisation; auto-escaping is the only default protection, and |safe bypasses it — turning a <script>-bearing filename into a stored XSS vector. The matcher is anchored on the {{ ... }} variable form (not a bare substring), tolerates whitespace around the pipe, and word-boundary guards reject notclient_name / client_name_foo. Honours DJUST_CONFIG['suppress_checks'] (S007 or djust.S007). New cases in TestS007ClientNameSafeRegex and TestS007CheckIntegration in python/tests/test_checks.py.

Changed

  • Modularized checks.py (4,268 LOC) into a checks/ package (#1822). Pure refactor, no behavior change: the monolithic system-checks module is split by check family into checks/{utils,configuration,integrations,components,security,templates,accessibility,quality}.py, with checks/__init__.py firing every @register("djust") for Django AppConfig.checks discovery and re-exporting every public + private symbol. All 13 registered checks, 72 check IDs, and the full public/private import surface are preserved; the entire test_checks* suite passes untouched (zero test edits). The six helpers the suite monkeypatches by package path (_get_project_app_dirs, _has_asgi_server, _has_multiple_permission_groups, _check_tailwind_cdn_in_production, _check_missing_compiled_css, _check_manual_client_js) are referenced from their callers via the root module so patch("djust.checks.<helper>") keeps working. New regression guard in python/tests/test_checks_package_structure_1822.py pins the discovery, import-surface, and monkeypatch-by-path contracts.

Fixed

  • VDOM: {% if %} inside {% for %} no longer drops a row on re-render (#1832, P0 data-loss). A conditional inside a loop was wrapped in <!--dj-if id="if-<hash>-N"--> markers where N is the parser's compile-time ordinal, so every loop iteration reused the same id. A later re-render that repositioned one boundary emitted a MoveSubtree whose id matched N identical markers — the client got N unpairable moves (close marker not found), most patches failed, and the recovery morph visibly dropped a row per toggle. Fix: the renderer now threads a per-iteration loop-index path through the render context (mirroring the existing {% cycle %} counter save/restore) and appends it to the marker id, so each rendered {% if %} boundary inside a loop gets a unique id (if-<hash>-N-<index>, composing for nested loops) that is stable across re-renders which don't change loop structure. Ids outside any loop are unchanged (if-<hash>-N); the id is treated as an opaque string by the strip regex, the Rust differ, and the JS client. Distinct from #1826/#1828 (relative-vs-absolute move decision). Rust + Python regression tests added; two pre-existing tests that asserted the duplicate-id behavior were corrected.

  • dj-if MoveSubtree keyed on RELATIVE position, not absolute offset (#1826). A {% if %}-wrapped element inside a {% for %} loop dropped a table row per toggle (P0; latent since v1.0.0 — introduced with the MoveSubtree feature in #1666, not new in 1.0.6rc1; the reporter happened to hit it on 1.0.6rc1). diff_html's matched-boundary move-decision in crates/djust_vdom/src/diff.rs compared ABSOLUTE child offsets (old_off + ob.open vs new_off + nb.open). Filling an EARLIER empty dj-if body inserts nodes that shift the absolute index of every LATER boundary even though those boundaries did NOT move relative to their siblings — so the differ emitted spurious MoveSubtree { id: "if-b-0" / "if-c-0" } ops the client could not pair (close marker not found), ~15/22 patches failed, and an html_recovery morph dropped a row. The decision now keys on the boundary's position RELATIVE to its non-boundary siblings (a new non_boundary_count_before helper over the existing excluded mask) plus its ordinal among same-level boundaries; both are invariant to a sibling boundary's span-length change, so only a GENUINE reposition (a real element inserted/removed before the boundary, or a boundary reorder) emits a move. The move TARGET index is unchanged. The #text-flattening also seen in #1826 (Defect 1) is an html5ever foster-parenting artifact of a bare <tbody> fragment (no <table> ancestor); it does not reproduce in production and is scoped out to follow-up #1827. Covered by the Rust reproducer crates/djust_vdom/tests/test_dj_if_loop_spurious_move_1826.rs (loop-fill no-spurious-move + client-faithful round-trip + genuine-reposition guard; gate-off verified per #1468) and 4 regression cases in python/djust/tests/test_diff_html_if_marker_rows_1826.py. The two #1666 guards and the proptest / torture dj-if round-trip nets stay green. The ORIGINAL #1826 symptom was CLIENT-side (applyMoveSubtree_findDjIfCloseMarkerclose marker not found), and the Rust apply_all harness is not faithful to the JS client's document-wide depth-counting TreeWalker; new cases in the dj-if MoveSubtree — JS-client apply (#1826 follow-up) describe block (tests/js/dj_if_movesubtree_client_apply_1826.test.js) exercise the REAL src/12-vdom-patch.js against the corrected diff_html patch streams for the all-fill, genuine-reposition, redundant-move, and nested-boundary shapes — closing the JS-apply coverage gap the server-only tests left open (the real client passes all four; no client change needed).

  • Consumer-owned monotonic VDOM send-version (#1788). The WebSocket version stamped on every client-checked frame was the Rust view's internal counter, which resets on a mid-session VDOM baseline loss (e.g. the patch-compression _rust_view.reset() path). The resulting non-sequential version failed the client's clientVdomVersion === data.version - 1 check, forcing an html_updaterequest_html recovery round-trip (a full page reload before #1785). The consumer now owns a monotonic per-connection counter (_next_version()) used as the single source of truth across every client-checked send path (events, async work, mount, server_push, db_notify, ticks, time-travel, hot-reload patches, and StreamingMixin.push_state), so a post-baseline-loss html_update stays in sequence and the client accepts it directly with no recovery round-trip. Recovery (html_recovery) now carries the consumer version of the frame it replaces. New regression tests in python/djust/tests/test_ws_send_version_1788.py pin the monotonic sequence across the baseline-loss boundary plus the send-path call-site coverage.

Security

  • Validate client-supplied mount/redirect URL (#1819). The WebSocket mount and sticky-child live_redirect frames carry the current page URL, which the consumer fed straight into RequestFactory.get(), resolve(), query-string concatenation, and log statements at two sites in python/djust/websocket.py without validation. RequestFactory does not normalize .. segments, so a crafted url of ../../admin/ landed in request.path as /..../admin/ (path traversal; an auth/routing decision keyed on request.path would see the traversed path); absolute (https://evil.com/page) and protocol-relative (//evil.com/page) URLs were silently accepted as relative requests, and the raw value flowed into logs and urlencode concatenation (CRLF / log-injection surface). A shared module-level helper _validate_mount_url() is now applied at both mount sites (one helper, two call sites — the structural cure per #1646): it rejects any url that is empty / non-string / does not start with /, contains a carriage-return or line-feed, is absolute or protocol-relative, or contains a .. path segment — falling back to /. Legitimate site-relative URLs (e.g. /dashboard?q=1) pass through unchanged. New regression cases in python/djust/tests/test_security_mount_validation.py pin the empirical Django behavior, the helper's reject/preserve contract, the validated-url-is-safe-for-RequestFactory end-to-end property, and a both-sites-validate source guard.

  • Bumped four transitive dependencies to clear 12 Dependabot advisories (4 high, 3 moderate, 5 low) (#1831). Lockfile-only (uv.lock) — none are direct djust dependencies, so the published wheel's declared dependencies are unchanged; this secures djust's own resolved / CI environment. cryptography 46.0.7 → 49.0.0 (GHSA-537c-gmf6-5ccf), pyjwt 2.12.1 → 2.13.0 (GHSA-xgmm-8j9v-c9wx, GHSA-993g-76c3-p5m4, GHSA-w7vc-732c-9m39, GHSA-jq35-7prp-9v3f, GHSA-fhv5-28vv-h8m8), python-multipart 0.0.29 → 0.0.32 (GHSA-5rvq-cxj2-64vf, GHSA-6jv3-5f52-599m, GHSA-vffw-93wf-4j4q, GHSA-v9pg-7xvm-68hf), starlette 1.2.1 → 1.3.1 (GHSA-82w8-qh3p-5jfq, GHSA-jp82-jpqv-5vv3). Full suite green against the bumped versions.

  • Audited event-handler type-coercion edge cases — no bypass found, behavior pinned (#1820). validate_handler_params() coerces event params by default (coerce=True) because Template data-* attributes always arrive as strings. The audit empirically exercised the malformed/adversarial inputs from the issue against the real coercion code and confirmed the paths are safe by design — no code change was required: (int) page="999 OR 1=1" and hex id="0x41" make int() raise, so the original string is kept and type validation rejects the event (valid is False, handler not invoked) — there is no silent truncation to 999; (bool) the dangerous case — active="true; DROP TABLE" — coerces to False because bool coercion is an allowlist (value.lower() in {"true","1","yes","on"}), NOT bool(non_empty_string), so the falsy-but-non-empty "false"/"0" are also False (no truthiness logic-bypass); (float) malformed strings are rejected, while "1e309"/"inf"/"nan" are accepted as the valid Python floats they are (intentional, documented contract — handlers doing bound checks or arithmetic on a coerced float must guard non-finite values themselves); (List[T]) a malformed element abandons the whole coercion (no partial [1,2]) and the subscripted generic is skipped by the type validator, so the handler receives the unmodified original string. The strictest posture remains @event_handler(coerce_types=False), which rejects any string for a typed param outright (so no separate @strict_types decorator was added). The audited contract is documented in SECURITY_AUDIT.md (Type Coercion Contract table) and pinned by TestCoercionSecurityEdgeCases (11 characterization cases) in python/tests/test_validation.py; non-tautology was verified (#1468) by mutating the coercion to the unsafe variants and confirming 5 of the new tests fail with the exact dangerous symptoms.

[1.0.5] - 2026-06-15

Added

  • New system check djust.V012 — warns when a sticky-child template declares its own dj-view (nested duplicate binding) (#1803). A sticky child is embedded via {% live_render "...Path" sticky=True %}, which makes the framework emit the wrapper element itself — <div dj-view dj-sticky-view="<id>" dj-sticky-root data-djust-embedded="<id>"> (static/djust/src/45-child-view.js). If the child's own template root also carries a dj-view attribute, the rendered page ends up with a nested, duplicate dj-view inside the wrapper — the child's client-side mount breaks and its dj-click / dj-input events silently don't bind. This is a subtle footgun because normal page views require dj-view="<path>" on their root to be browser-mountable, so authors (and code-generating agents) reasonably add it everywhere, including sticky children, where it's wrong. Until now the only safeguard was a comment inside one example template. djust.V012 (LiveView/V category, Warning) walks LiveView subclasses with sticky = True, resolves each one's template source, and scans for a <div ... dj-view ...> root tag — converting the silent footgun into a manage.py check warning. False-positive guards: only sticky = True views are inspected (normal page views, which legitimately declare dj-view, are never flagged); the scan uses an anchored <div ... dj-view ...> opening-tag regex, not a bare substring; {% comment %} / {# #} / <!-- --> regions are stripped first so a dj-view documented inside a comment (e.g. the demo's audio_player.html wrapper-example comment) is ignored; internal djust classes are skipped unless their module is a test/example. Suppress with DJUST_CONFIG = {'suppress_checks': ['V012']}. Documented on the sticky LiveViews guide and the system-checks reference. New regression cases in TestV012StickyChildOwnDjView (tests/unit/test_checks_v012_sticky_own_dj_view.py) cover the positive trigger, the comment-only / non-sticky-page-view / correct-child silent cases, suppression, the #1459 empirical canary (a fresh type()-built sticky child), and the #1468 gate-off self-test. Empirically validated against manage.py check on the demo project (0 false positives; fires on an injected real footgun).

Fixed

  • Flaky test_total_wall_clock_is_max_not_sum made deterministic (#1795). The parallel-lazy-render concurrency test asserted a wall-clock ratio (parallel < serial/2); under full make test -n auto CPU saturation the 3 concurrent thunks couldn't get dedicated cores and the speedup ratio drifted past 0.5, false-failing the release make test (observed parallel=88.1ms vs threshold=85.8ms at the 1.0.5rc5 cut; passed in isolation). It now proves concurrency via deterministic interval overlap — each thunk records its [start, end] and a concurrent render satisfies max(start) < min(end) (all thunks start before any finishes), immune to timing jitter because launching N coroutines takes microseconds regardless of load. A new gate-off sibling, TestParallelRender::test_overlap_proof_rejects_a_serial_loop, pins that a serial loop does NOT overlap, so the proof stays non-tautological. Test-only; no production change.

  • html_recovery no longer resets an embedded sticky child to its mount() defaults — P0 data loss (#1813). On an HTTP-prerendered page embedding {% live_render "Child" sticky=True %}, after a user interacted with the sticky child a failed parent patch triggered html_recovery, which reset the sticky child to mount state and discarded the interactions. Two compounding defects, fixed together: (b1) the structural cure{% live_render sticky=True %} (python/djust/templatetags/live_tags.py) constructed a fresh child_cls() + mount() on every parent render (the two pre-existing escape hatches — _sticky_preserved auto-reattach and session-backed restore_sticky_child_state — are inert in the default config, the latter gated behind enable_state_snapshot=True), so every parent re-render and every _recovery_html snapshot rendered the child at mount defaults; now a live-instance-reuse hatch re-renders the parent's already-registered live child (_get_child_view(sticky_id) from the StickyChildRegistry) instead of mounting fresh, independent of enable_state_snapshot and composing with (not bypassing) the existing hatches. The shared _render_sticky_child_html helper keeps the fresh-mount and reuse paths byte-identical (parallel-path-drift guard, #1646). (b2)(ii) recovery freshnesshandle_request_html (python/djust/websocket.py) now re-renders the parent fresh when it has live sticky children (the embedded-child event branch sends a scoped embedded_update and deliberately does not re-arm recovery, so the cached _recovery_html was stale); re-rendering at recovery time is correct + lowest-overhead (recovery is rare) and faithful only because (b1) makes the re-render reflect the live child — non-sticky pages keep the cached-replay path unchanged. (a) the client-side trigger — the #1610 prerender skipMountHtml morph keys morphChildren by node.id, but the sticky wrapper (<div dj-view dj-sticky-view dj-sticky-root data-djust-embedded=...>) has no id and only aligns positionally, so once a preceding sibling count diverges the server dj-id was never stamped onto the live wrapper and the first parent patch fell back to a positional path that broke on child drift; _stampEmbeddedWrapperDjIds() (static/djust/src/03-websocket.js, bundle rebuilt) now runs after the morph and copies the server dj-id onto each live wrapper matched by its stable data-djust-embedded value (the 45-child-view.js selector). Regression coverage: 5 WebsocketCommunicator end-to-end cases in python/djust/tests/test_sticky_child_recovery_1813.py (default config, gate-off verified for both b1 and b2) and 7 cases in tests/js/ws-mount-prerender-divergence-1813-sticky-djid.test.js (gate-off verified for the dj-id stamp).

  • A worktree git push now runs the pre-push pytest suite against the worktree's Python source, not the main checkout's (#1810). #1796 fixed interpreter resolution from a git worktree, but the editable maturin develop install binds Python imports to the main checkout via a plain djust.pth that appends <main>/python to sys.path — so a git push from a linked worktree ran the pre-push suite against the main tree's source, silently passing/failing on code the worktree never changed (worktree pushes still needed --no-verify, leaving CI as the only correct gate). Root cause confirmed empirically: the .so-less worktree + plain .pth (not an __editable__ meta-path finder) means PYTHONPATH — which Python inserts before .pth processing — wins when it points at the worktree's python/, while a bare worktree import resolves the main tree (a sentinel added to the worktree's __init__.py was invisible without the prepend, visible with it). Fix: a new scripts/run-with-venv-python.sh --worktree-pythonpath mode emits the current worktree's python/ dir to prepend to PYTHONPATH (a no-op — empty output — in the main checkout or outside a git tree) and symlinks the matching compiled _rust.<cache_tag>-*.so from the main checkout into the worktree's python/djust/ so import djust._rust keeps resolving once the Python source is shadowed (the .so is gitignored, so the symlink never appears in git status). The pre-push pytest hook in .pre-commit-config.yaml now prepends this path. Caveat (documented in CONTRIBUTING.md): this shadows only Python source — Rust (djust._rust) changes still need maturin develop run against the worktree; CI remains authoritative. New cases in TestWorktreePythonpath (tests/test_run_with_venv_python.py): worktree path-emit, main-checkout/no-package no-op, the behavior-meaningful PYTHONPATH-shadow precedence test (with a gate-off proving the prepend is load-bearing — without it the main source wins, the exact #1810 bug), the .so symlink, and an entry-line source-pin on the config wiring (gate-off: reverting the entry line fails it).

  • System check djust.T004 no longer flags document.addEventListener for djust events that are dispatched on document, and now honors suppress_checks (#1809). T004 (document.addEventListener('djust:...') → use window) had two defects. (1) False positive that broke correct code: it assumed all djust: events dispatch on window, but djust dispatches a whole family on documentdjust:navigate-start, djust:navigate-end, djust:hvr-applied, djust:layout-changed, djust:ws-reconnected, djust:time-travel-state, djust:time-travel-event (sourced from the client bundle's document.dispatchEvent(new CustomEvent('djust:...')) sites in static/djust/client.js / src/03-websocket.js / 18-navigation.js / 40-dj-layout.js). Listening for those on document is correct, yet T004 flagged them and told the user to switch to window, which would break the listener (it would never fire). (2) Unsuppressible: the emission loop never called _is_check_suppressed, so DJUST_CONFIG = {"suppress_checks": ["T004"]} was a no-op. Fix: _DOC_DJUST_EVENT_RE now captures the event name; a new module constant _DOC_DISPATCHED_DJUST_EVENTS (frozenset, cited to the client.js dispatch sites) is used to skip the document-dispatched family; and the emission loop is gated on _is_check_suppressed("djust.T004") (mirrors T002/C013), so both the ["T004"] and ["djust.T004"] forms now silence it. Window-dispatched events (djust:push_event, djust:before-navigate, djust:error, djust:shell-swapped, djust:vdom-cache-applied, djust:upload:*) still warn — the legitimate purpose of T004 is preserved. New cases in TestT004DocumentDispatchedEvents (navigate-end + every document-dispatched event not flagged; window-dispatched push_event still flagged) and TestT004Suppress (fires without suppression; silenced via both short and qualified IDs) in python/tests/test_checks.py. Gate-off verified: disabling the allowlist makes the document-event tests fail; disabling the suppress guard makes the suppress tests fail. Docs updated in docs/system-checks.md and docs/guides/error-codes.md.

  • Embedded sticky-child ({% live_render "...View" sticky=True %}) events now produce a patch instead of a bare noop (#1802). A sticky / embedded child widget's dj-click (and other) events did nothing in the browser: the event routed to the child's handler correctly and the handler ran, but the consumer returned {"type": "noop"} — no patch/HTML was sent — so the child's DOM never updated. Sticky/app-shell widgets (a headline feature) were effectively render-only / non-interactive; the workaround was to move the handler + state onto the page view. Root cause (traced symptom-up against a real WebsocketCommunicator, confirming the noop): the auto-skip-render block in LiveViewConsumer.handle_event snapshotted public assigns on self.view_instance (the PARENT) both before and after the handler. Embedded-child events route via view_id so target_view is the CHILD; the handler mutates the child, leaving the parent's assigns unchanged → pre_assigns == post_assignsskip_render = True_send_noop fired BEFORE the embedded-child render branch (which builds the scoped embedded_update frame) could run. Fix (Python-only): bind change_target = target_view and take the pre/post assigns + push-command identity snapshots — and read _skip_render/_force_full_html/_pending_push_events, write _changed_keys — against change_target. For a top-level event target_view IS self.view_instance, so the common path is unchanged; for an embedded child the mutation is now detected and the existing embedded_update frame (full child HTML, applied client-side via 45-child-view.js's handleEmbeddedUpdate against [data-djust-embedded]) is sent. The LiveComponent (component_id) path and existing sticky render/redirect/persistence behavior are unaffected. Regression coverage: test_embedded_sticky_child_event_produces_update_not_noop (real WebsocketCommunicator: mounts a parent embedding a sticky=True NotificationsView, fires dismiss with view_id in params, asserts an embedded_update reflecting the mutated state — not noop), a standalone control, and a change_target source pin in python/djust/tests/test_sticky_child_event_noop_1802.py. Gate-off (change_target = self.view_instance) makes the integration test fail with {'type': 'noop'}; tests/integration/test_sticky_redirect_flow.py and test_sticky_http_get_1784.py still pass.

  • App-template dir collector uses is_dir() to match DjustTemplateBackend (#1805). utils._get_template_dirs_cached (the cached APP_DIRS collector used by the shell render) guarded each app's templates path with exists(), while djust's own DjustTemplateBackend._get_template_dirs (template/backend.py) uses is_dir(). The two parallel-path collectors disagreed: with exists(), a plain file literally named templates (no extension) would be wrongly added to the template search dirs. Switched the cached helper to is_dir() so both reject non-directories identically (pre-existing tech-debt surfaced in the #1804/#1801 review; harmless in practice). New cases in TestCollectorIsDirGuard pin that a file named templates is excluded while a real templates/ directory is still collected (gate-off verified against the pre-fix exists() guard). Also documents that the test_resolution_failure_is_logged_not_silent monkeypatch target depends on the from djust._rust import resolve_template_inheritance import staying inside get_template().

  • {% extends %} pages now keep the base template's <head> on the initial HTTP GET (#1801). A LiveView whose template {% extends "base.html" %} served an initial GET containing only the dj-root subtree (no <!doctype>/<html>/<head>/<title>/<style> from the base template), so every template-inheritance page — including the untouched djust new scaffold — rendered unstyled on first paint. Root cause (traced symptom-up against the real scaffold): get_template() collected the Rust resolver's template search directories with a hardcoded backend-name check that recognized only django.template.backends.django.DjangoTemplates. The scaffold (and any project) configuring djust's own backend djust.template.backend.DjustTemplateBackend with APP_DIRS=True had its app-template directories silently dropped, so resolve_template_inheritance raised RuntimeError: Template error: Template not found — which was swallowed by a broad except Exception that logged only at DEBUG and set self._full_template = None. render_full_template then fell through to its else (return self.render(request)) → the bare dj-root fragment with no shell/head. Two-part fix: (1) the APP_DIRS dir-collection now recognizes the djust backend(s) via a shared utils._APP_DIRS_TEMPLATE_BACKENDS set, and get_template() resolves dirs through the single get_template_dirs() helper it already shared with render_full_template step 2 — retiring the parallel-path-drift between the two (#1646): utils._get_template_dirs_cached() (used by the shell render) had the identical hardcoded check, so a point fix in only one place would have left the shell render broken for the same reason. (2) The broad swallow is narrowed to scope only the resolve_template_inheritance call (the legitimate raw-template fallback for genuinely-unresolvable templates) and now logs at WARNING — post-resolution VDOM extraction/strip is moved outside the try so an unexpected framework error surfaces instead of silently degrading to fragment-only. Verified against a fresh djust new demo --no-setup GET: the response now starts with <!DOCTYPE html> and includes the base <head>/<title>/<style>. Regression coverage: test_extends_get_includes_base_head, test_full_template_is_populated_for_extends, test_app_template_dirs_collected_for_djust_backend, and test_resolution_failure_is_logged_not_silent in tests/integration/test_extends_head_initial_get_1801.py drive the real as_view() GET under djust's own backend (the exact config the bug reproduces under); gating the backend-set fix off makes three fail fragment-only and gating the WARNING off makes the logging test fail (silent-catch pin). Non-extends LiveViews and the existing test_sticky_http_get_1784.py / SSR-parity suites are unaffected.

  • Serial-order test pollution that broke test_checks S005 + auto_navigate_meta (#1794). Under the broad serial pytest ordering (pytest python/ / make test-python), three tests failed that pass both in isolation and under the parallel make test -n auto gate (which isolates per-worker and never collects python/djust/tests/): test_checks.py::TestS005UnauthenticatedViews::test_s005_suppressed_with_login_required_false, and test_client_config_tag.py::test_auto_navigate_meta_emitted_when_enabled / ::..._engines_identical. Two independent polluters, neither a settings.DATABASES / LIVEVIEW_CONFIG leak (the reported hypothesis): (1) python/djust/tests/test_ws_auth_close_socket.py defines a module-level LiveView subclass _PublicView with no login_required and exposed state (self.ok), so it permanently joins LiveView.__subclasses__() and the djust.S005 check fired on it ("PublicView" in msg) for any later test asserting the S005 result set — fixed by marking it login_required = False ("intentionally public"), which is also its actual contract; (2) tests/unit/test_ws_compression_config.py::_fresh_config called importlib.reload(djust.config), rebinding djust.config.config to a new singleton while every from djust.config import config consumer (notably djust.templatetags.live_tags) kept the old reference — so the auto_navigate tests reset the new singleton while live_tags read the stale one and never emitted the <meta> — fixed by re-reading settings via config.reset() on the shared singleton (same effect, no orphaning) plus an autouse teardown fixture. Verified with three consecutive clean serial pytest tests/ python/tests/ python/djust/tests/ runs (7692 passed each). Test-only changes; no framework behavior change.

  • Pre-push hook (and make targets) now resolve the project venv from any git worktree (#1796). The native pre-push hook entries in .pre-commit-config.yaml — and ~31 make targets — hardcoded .venv/bin/python relative to the current working directory. A git worktree (e.g. the ones pipeline-drain subagents create under .claude/worktrees/) has no .venv of its own, so the hook failed with bash: .venv/bin/python: No such file or directory (exit 127), forcing git push --no-verify and skipping the real gates. New scripts/run-with-venv-python.sh resolves the interpreter relative to the MAIN working tree root (dirname of the absolute --git-common-dir, which points at <main-root>/.git for both the main checkout and every linked worktree), falling back to uv run python then python3 on PATH when no .venv exists (CI, fresh clone). All 7 hook entries route through it, and the Makefile's hardcoded references collapse to a single $(PYTHON) variable computed once via the resolver — so the pre-push gates and make test both run from any worktree instead of erroring. 6 regression cases in tests/test_run_with_venv_python.py (real git worktree resolution, main-checkout no-regression, python3 fallback, no-interpreter error, plus source-pins on the config and Makefile); the worktree case fails against the pre-#1796 resolver via the gate-off self-test.

  • djust new scaffold is now warning-clean and the deprecated cli.py startproject twin no longer ships broken templates (#1791, follow-up to #1787/#1790). After #1790 fixed the boot blockers, a fresh djust new project passed manage.py check (exit 0) but still emitted five warnings; it now emits zero. Fixed in the canonical scaffolder (python/djust/scaffolding/templates.py + generator.py): C012base.html loads {% load live_tags %} and uses {% djust_client_config %} instead of a manual <script src=".../client.js"> tag (the LiveView post-processing pipeline auto-injects client.js, so a manual tag double-loads); S005 — the in-memory demo view (and the --with-db demo view) declares login_required = False to acknowledge it is an intentionally-public to-do list with no per-user data; Y001/Y003index.html's icon-only toggle/delete buttons get aria-labels and the search/add-item inputs get aria-labels; A030django.contrib.admin is now opt-in (the default in-memory scaffold omits it, eliminating the brute-force-protection warning and the admin-only second DjangoTemplates backend), while --with-db/--from-schema still wire admin + its template backend + the /admin/ URL, where A030 fires by design as correct security guidance. Separately, the deprecated djust startproject command carried its own divergent project templates that still shipped the broken application = live_session() ASGI app and the dropped daphne stack (the same #1787 bug); rather than maintain a second drift-prone template set (parallel-path-drift), cmd_startproject now prints a deprecation notice and delegates to the canonical generate_project(), producing the same warning-clean, uvicorn-booting project. Regression coverage: TestScaffoldWarningClean1791 (tests/integration/test_scaffold_boot_1787.py) asserts a fresh scaffold's manage.py check emits zero WARNINGS and none of djust.C012/S005/Y001/Y003/A030; the rewritten TestStartProjectDeprecated (python/tests/test_cli_scaffold.py) pins the deprecation+delegation contract. Gate-off self-test confirmed the warning-clean assertion is non-tautological.

  • Request + context-processor outputs no longer leak into persisted LiveView state (#1786). A LiveView whose mount() assigned only JSON-serializable scalars still emitted, on every render/event, a flood of serialization warnings naming the request and the standard context-processor outputs (ASGIRequest/WSGIRequest, auth PermWrapper, messages FallbackStorage, SimpleLazyObject/UserLazyObject) — values the view never assigns to self. It also bloated the _prev_context_refs change-detection fingerprint (the dict '_prev_context_refs' has N keys — fingerprint truncated warning) and inflated the state written to the Redis state backend. Root cause: _sync_state_to_rust folds the request + context-processor outputs into the render context via _apply_context_processors; on the first render (and on every event, since those values get a fresh id() each cycle) they flowed through normalize_django_value (one warning per value) and into the _prev_context_refs fingerprint. Fix: _apply_context_processors now records the keys it added on self._context_processor_keys; _sync_state_to_rust excludes those keys (plus request) from the change-detection fingerprint and the set_changed_keys skip set, and skips the non-serializable ones from the update_state/normalize_django_value warning path. The non-serializable values still reach the Rust template via the existing raw-value sidecar (set_raw_py_values), so {{ user }} / {% csrf_token %} keep rendering (the #1779 contract is preserved); genuine user-assigned public state is untouched, so WS-reconnect restore and time-travel snapshots are unaffected. Regression coverage in TestContextProcessorStateLeak1786 (python/tests/test_context_processor_state_leak_1786.py) — asserts zero non-serializable warnings on render, the request-scoped keys are absent from _prev_context_refs and the serialized Rust state, and {{ user }}/{% csrf_token %} still render; fails against the pre-#1786 code.

  • Embedded {% live_render "...View" sticky=True %} now server-renders on the initial HTTP GET (#1784). Any page whose template embedded {% live_render %} returned HTTP 500 on the first load — so sticky / app-shell pages (a headline feature) could not be server-rendered at all. The page shell (including the live_render tag) is rendered through the Rust engine with a JSON-serialized context that structurally cannot carry the live parent LiveView object; the tag looked the parent up via context.get("view")/context.get("self") (both absent) and raised TemplateSyntaxError: {% live_render %} must be called inside a LiveView template; no parent view in the current render context. Fix (no Rust changes): an active-parent-view thread-local + active_parent_view() context manager (save/restore, nesting-safe, always cleared on error) in djust.templatetags.live_tags; both render_full_template and render_with_diff register self as the active parent for the duration of their Rust render (both re-run the tag through the Rust engine on the GET path), and live_render falls back to the thread-local when the render context has no view/self and to the parent's live request when the JSON-serialized request was stringified. The WS / Django-engine paths carry a real view in context, so the fallback is inert for them and existing sticky preservation across live_redirect is unchanged. sticky_demo (the only embedded-{% live_render %} example app, and the only demo app not wired into the demo project) is now wired into demo_project urls + INSTALLED_APPS so the initial-GET server-render path is exercised end-to-end — the gap that let the bug ship unexercised. Regression coverage in tests/integration/test_sticky_http_get_1784.py (test_sticky_live_render_http_get_returns_200, test_sticky_live_render_http_get_includes_child_html, and test_sticky_demo_dashboard_http_get_200) drives the real as_view() GET path for both the inline-template and template-inheritance branches; all three fail against the unfixed render_full_template.

  • djust new now scaffolds a project that actually boots (#1787). The generated asgi.py did application = live_session(), but live_session(prefix, patterns, ...) is a URL-pattern helper (returns List[URLPattern]), not an ASGI app — importing the scaffolded asgi.py raised TypeError: live_session() missing 2 required positional arguments, so make dev crashed immediately. The asgi template now lifts the demo_project/asgi.py pattern: a ProtocolTypeRouter whose "http" is ASGIStaticFilesHandler(get_asgi_application()) (serves client.js/CSS under uvicorn with no WhiteNoise) and whose "websocket" is AllowedHostsOriginValidator(AuthMiddlewareStack(URLRouter([path("ws/live/", LiveViewConsumer.as_asgi())]))), with get_asgi_application() called before the channels/djust imports so the app registry is populated. The dev stack moves off daphne to uvicorn: the Makefile dev target now runs uvicorn <name>.asgi:application --host 127.0.0.1 --port 8000 --reload, requirements.txt drops daphne>=4.0 for uvicorn[standard]>=0.30, and INSTALLED_APPS lists "channels" instead of "daphne". Separately, a freshly-scaffolded project failed manage.py check (which blocks migrate) on two ERRORS: djust.A014 (the scaffold ran in production mode — settings.py read os.environ but never loaded the generated .env, so DEBUG was False and the django-insecure- key was flagged) and admin.E403 (no DjangoTemplates backend for the admin). settings.py now ships a dependency-free .env loader (os.environ.setdefault per KEY=VALUE line, comments/blanks skipped) and the scaffolder writes a working .env (DEBUG=True + a real SECRET_KEY, gitignored) so dev mode is on out of the box; a second DjangoTemplates TEMPLATES backend satisfies the admin. The django-insecure- prefix is retained as the production marker (A014 still fires in real DEBUG=False deploys). manage.py check now exits 0; the remaining warning-level items (C012 manual client.js, S005 unauth view, Y001/Y003 aria) are deferred to a follow-up. Regression coverage in test_scaffold_boot_1787.py (test_scaffold_asgi_imports_and_check_passes asserts the generated asgi.py imports + application is a callable ASGI app, and manage.py check exits 0; fails against the pre-#1787 templates).

  • WebSocket recovery no longer forces a full page reload on the html_update fallback (#1785). When a LiveView event's VDOM diff returns no patches and the server sends a full-HTML html_update frame (the DJE-053 fallback), it now arms on-demand recovery — matching the patches path. Previously the html_update branch in handle_event skipped _arm_recovery, so a client that subsequently requested recovery (e.g. after a VDOM version mismatch on the full-HTML frame) received Recovery HTML unavailable — the server may have restarted and reloaded the whole page instead of morphing. Surfaced by a multi-replica djust.org /insights/ page reloading on every time-range switch. Added a WebsocketCommunicator regression test (TestWSRecoveryHtmlUpdate-style, in test_ws_recovery_html_update_1785.py) plus a source pin.

[1.0.4] - 2026-06-13

Added

  • auto_navigate — opt-in automatic SPA link interception (#1734, ADR-021 Stage 2). With dj-navigate you annotate each link; auto_navigate goes one step further — a single delegated click listener SPA-navigates plain <a href> links (no djust attribute needed) whenever the link's path resolves in the route map. Enable via LIVEVIEW_CONFIG['auto_navigate'] = True (default OFF); {% djust_client_config %} then emits a <meta name="djust-auto-navigate"> flag (CSP-clean, no inline script) and the client installs one delegated document listener. It is deliberately conservative — a link falls through to a normal browser navigation on a modifier/middle click, a target other than _self, a download attr, rel="external", a data-no-navigate ancestor, an external origin or non-http(s) scheme, a same-page hash-only jump, or any path not in the (auth-filtered, #1758) route map — so admin pages, plain Django views, and routes the user can't access reload normally and the server enforces access. Same-view query-only changes use live_patch (state-preserving); cross-view uses live_redirect. Native dj-navigate/auto_navigate is positioned as djust's canonical SPA model; turbonav-integration.md is reframed as interop (#1735). New TestRouteMapAuthFilter-adjacent JS suite (tests/js/auto_navigate_1734.test.js, 15 cases incl. the full opt-out matrix) + TestClientConfig emit tests.
  • djust deploy preflight "deploy doctor" warns on settings that violate the platform env contract (#1760). A foreign (non-scaffold) app — one written for on-device/loopback use — could deploy "successfully" and then 500 silently in production because its Django settings ignore the env values the platform injects. djust deploy / djust deploy-dir now run a non-blocking static preflight (_run_deploy_doctor) over the resolved settings module (located via manage.py's DJANGO_SETTINGS_MODULE, falling back to the shallowest settings.py) and print warnings to stderr — never errors, the deploy still proceeds — when it finds: a hardcoded literal SECRET_KEY (dev key on a public host), an ALLOWED_HOSTS literal not read from env (→ DisallowedHost 400), a DATABASES block that never consults DATABASE_URL (→ OperationalError: readonly database on the read-only app rootfs), or a sqlite ENGINE (sqlite under the read-only project dir 500s on first write). Each warning ends with the env-injection pointer. The checks are grep-level static inspection of the settings source text — the module is never imported, so an unimportable foreign settings file can't break the doctor, and the whole pass is wrapped fail-soft so a doctor error can never block a deploy. Prevents the silent "successful deploy, 500s at runtime" chain that cost a multi-hour production debug. 14 new cases in TestDeployDoctor.
  • djust deploy shows "rolling out" instead of "active" while a blue/green rollout is still serving stale code (#1761). djustlive's deployment_status endpoint now returns an additive serving_current boolean (djustlive #517) — False while a new rootfs is built and marked current but the old placement is still serving the env URL during cutover. The deploy-dir poll loop now consumes it: while the deployment row reads active/deploying but serving_current is False, the CLI prints Status: rolling out (new version built; old version still serving — waiting for cutover) and keeps polling, instead of reporting active and printing the URL on stale code (which led users to re-test against the old rootfs). A missing field is treated as True (fail-safe), so the behavior is byte-identical against older servers that don't send it. The poll decision is extracted into a pure _poll_display(data) -> (message, done, url) helper. 8 new cases in TestPollDisplay.

Security

  • Documented LIVEVIEW_ALLOWED_MODULES as recommended production hardening (#1778, threat model T4). The WebSocket mount allowlist is enforced only when non-empty; an unset list lets a client request mounting any LiveView by path (enumeration — not an auth bypass, since per-view auth still gates). Added guidance to docs/guides/security.md + docs/SECURITY_GUIDELINES.md to set it to your app's module prefixes in prod (djust.V005 flags views outside a non-empty list but cannot warn about an unset one).

  • Opt-in per-event auth re-check on the WebSocket path (#1777, threat model T3, defense-in-depth). Auth runs at mount; the connect-time scope user is cached, so an authenticated user who logs out or loses a permission mid-session keeps dispatching events on the open socket until they reconnect. New LIVEVIEW_CONFIG['reauth_on_event'] (default OFF): when enabled and the mounted view declares login_required/permission_required, handle_event re-resolves the user from the session (channels.auth.get_user) and re-runs the view's auth check, sending a navigate redirect + close(4403) + clearing view_instance on failure. Default OFF because it costs one session read per event — opt in for high-security apps that want mid-session deauthorization enforced on the live path. Fail-safe (skips the check, never breaks the event) when there is no session in scope. Behavior note: when the flag is on, the re-resolved (current) user is also written to request.user, so event handlers observe live auth state rather than the connect-time snapshot. Complements the T1/T2 mount-redirect fix.

  • WebSocket auth bypass fixed: a login_required mount redirect now closes the socket instead of leaving it open (threat model T1/T2). handle_mount sent a {"type":"navigate"} redirect frame on the login_required (and on_mount-hook) failure branches but did not close the socket — only the PermissionDenied branch closed it (4403). The view never mounted yet view_instance stayed set, and handle_event never re-checks auth, so a raw WebSocket client that ignored the navigate frame could send {"type":"event"} messages and reach @event_handler methods with no authenticated session — a full auth bypass on the live mutation path (a browser obeys the redirect and hides it). Reachable via both the initial mount and handle_live_redirect_mount (which delegates to handle_mount). Both redirect branches now send the navigate frame, then close(code=4403) and clear view_instance, mirroring the PermissionDenied branch; public/authorized mounts are unchanged. A WebsocketCommunicator reproducer (anonymous scope) proves the bypass pre-fix and the close post-fix. The complete WS auth/transport threat model — 9 threats, including T3 (the event path does not re-check auth mid-session) and T4 (LIVEVIEW_ALLOWED_MODULES is default-open), tracked as follow-ups — is documented in docs/audits/websocket-auth-2026-06.md.

  • The auto-emitted client route map is now auth-filtered — gated routes no longer leak to clients that can't access them (#1758, ADR-021 Stage 2). window.djust._routeMap was built by build_route_map_from_urlconf with no auth filtering and emitted to every client (including anonymous visitors), enumerating all LiveView routes — login_required / permission_required / admin ones included — each with its dotted module.QualName view-class path. An anonymous visitor to a public page therefore learned the full route table plus the internal view-class names of routes they cannot reach (recon-grade information disclosure; not an auth bypass — the WS mount path allowlists modules and views still enforce auth at mount). get_route_map_script(request) — the single funnel both template engines use — now omits any route whose LiveView declares login_required/permission_required (or whose callback is login_required(as_view())-wrapped, or which uses Django's LoginRequiredMixin/PermissionRequiredMixin) unless request.user satisfies it, and fails closed for anonymous / no-request callers. Public routes are unaffected, so existing public apps emit an identical map. A gating sidecar is built by the same single URLconf walk (no extra cost). New TestRouteMapAuthFilter cases + a dedicated fixture URLconf.

  • Bump PyO3 0.25 → 0.29 to fix two advisories — GHSA-36hh-v3qg-5jq4 (High) and GHSA-chgr-c6px-7xpp (Moderate) (#103, #104). Both advisories cover all PyO3 versions < 0.29.0 with no backport: an out-of-bounds read in nth/nth_back for PyList/PyTuple iterators (High), and a missing Sync bound on PyCFunction::new_closure closures (Moderate). No Dependabot PR existed for either. Bumps pyo3 and pyo3-async-runtimes 0.25 → 0.29 and migrates the FFI layer across the 0.26–0.29 breaking changes: Python::with_gilPython::attach and Python::allow_threadsPython::detach (GIL attach/detach rename), the removed PyObject alias → Py<PyAny>, Bound::downcastBound::cast, the reshaped two-lifetime FromPyObject trait (extract(Borrowed<…>) replacing extract_bound(&Bound<…>)), and the now-opt-in Clone auto-FromPyObject on #[pyclass] types (PyVNode opts in, SupervisorStatsPy opts out). Behavior-preserving: clippy clean under -D warnings, the full Rust suite (two-phase) and the full Python suite both pass against the rebuilt extension; no public API change.

Fixed

  • Deploy doctor no longer false-positives on env-derived DATABASES built from individual os.environ vars (#1768, follow-up to #1760). The #1760 DATABASES check warned whenever the settings text mentioned neither DATABASE_URL nor dj_database_url, so a perfectly environment-driven config assembled from individual os.environ['DB_NAME']/['DB_HOST']/… vars got a spurious "doesn't consult DATABASE_URL" warning. The env-read detection is now widened — the canonical DATABASE_URL/dj_database_url tokens still count anywhere, and other env reads (os.environ, os.getenv, python-decouple config()/env()) count within the DATABASES assignment region (a new brace-balancing _databases_region() scopes it, so an unrelated SECRET_KEY = os.environ[...] elsewhere can't mask a genuinely hardcoded DB block). 2 new cases in TestDeployDoctor.

  • Log the swallowed theme-context cache-write skip instead of silently passing (#2380). CodeQL flagged except (AttributeError, TypeError): pass in theme_context (python/djust/theming/context_processors.py) as an empty except. The branch intentionally skips the per-request cache write for request objects that can't hold arbitrary attributes (a __slots__ object in tests / exotic callers) — correctness over the micro-optimization — but the swallowed write was dropped with no trace. It now emits a logger.debug naming the request type and the exception, satisfying both CodeQL and the project's no-bare-except rule. Behavior is otherwise unchanged. New case in TestThemeContextCache.

  • djust deploy now respects .gitignore when building the deploy tarball, and warns before oversized uploads (#1759). _create_tarball previously walked the source tree using a hardcoded EXCLUDE_* list, ignoring the project's .gitignore. Non-standard names — a .venv-dev/ virtualenv carrying a 115 MB Playwright node binary, scratch/ screenshots, mobile-app/wheels/ — slipped straight in, producing a 152 MB tarball that the server's ingress rejected with a raw 413. Fix: when the source is a git work tree, _create_tarball takes its file list from git ls-files --cached --others --exclude-standard (tracked ∪ untracked, minus ignored), so the project's .gitignore is the source of truth. Non-git directories fall back to the existing os.walk path unchanged. The EXCLUDE_* security net (EXCLUDE_DIR_NAMES, EXCLUDE_FILENAMES, EXCLUDE_FILE_SUFFIXES, EXCLUDE_FILENAME_STEMS) still applies on the git path so credentials and live databases are dropped even if the user forgot to gitignore them (#1505 intent preserved). A new _tarball_size_warning prints an actionable warning to stderr before upload when the packed tarball exceeds 50 MB, listing the largest included files so the user can identify what to add to .gitignore instead of hitting a raw nginx 413 page. 6 new regression cases in python/tests/test_deploy_cli.py.

[1.0.3] - 2026-06-07

Added

  • dj-navigate keeps aria-current="page" in sync across SPA navigation (#1756). A persistent nav usually lives outside [dj-root]; since dj-navigate swaps only the [dj-root] subtree, a server-rendered active-link highlight previously stayed on the page you navigated from. The client now sets aria-current="page" on the [dj-navigate] link whose path matches the current URL (and removes it from the others) after each navigation — on click, on the WS mount, and on back/forward (popstate). Cross-origin dj-navigate targets are never marked current, and an app-authored aria-current of a different value is left untouched. Style the active link with a[dj-navigate][aria-current="page"]. (Syncing document.title and the [dj-root] dj-view attribute across SPA nav remain tracked in #1756.)

Fixed

  • render_full_template ejected page content outside [dj-root] for multi-line <div> tags, leaking it across dj-navigate (#1749). The dj-root region's closing </div> was located by counting <div> depth, but the opening-tag check only matched the exact forms "<div " and "<div>" — a multi-line opening tag (<div\n class=...> / <div\t...>) was not counted. Each missed open under-counted depth, so a later </div> closed the dj-root region early; the full rendered view was then spliced in place of the truncated region, leaving the tail of the page outside <div dj-root> (and duplicated). Because dj-navigate swaps only the [dj-root] subtree, that ejected content was never cleared on navigation (it leaked onto the next page — observed on a page authored with multi-line <div> sections, reached via a fresh GET). Fix: match <div followed by any tag-boundary char (whitespace, >, /). Same family as #1746.

  • dj-root boundary close tag missed whitespace (</div > / </div\n>), and two divergent scanners (#1751). Completes the #1749 fix class. _find_closing_div_pos (the shared scanner, 6 call sites) hardcoded the close as </div> — the close-side twin of #1749's open-side under-count, which would over-count depth and fail to find the close. Now matches </div\s*>. Also consolidated render_full_template's separate hand-rolled depth loop (whose open side was fixed in #1749/#1750) into the shared _find_closing_div_pos, removing the parallel-path-drift that let the open-side bug exist in one copy and not the other.

  • render_full_template double-rendered the whole page when a child template merely mentioned dj-root/dj-view as text (#1746). get_template() picked the VDOM source with a naive substring check ("dj-root" in template_source or "dj-view" in template_source). When the real <div dj-root> lived in the BASE template and the child only displayed the tokens in text (e.g. a <code> example) — or as a substring of another word (adj-view) — the check wrongly selected the child as the VDOM source, so render_full_template nested the entire page (two <!DOCTYPE>/two <footer>, truncated inline <script>, broken dj-navigate morphs). Replaced the substring check with the anchored-attribute regexes _DJ_ROOT_RE/_DJ_VIEW_RE already used elsewhere in the module, which require a real <div ... dj-root/dj-view ...> tag.

[1.0.2] - 2026-06-06

Added

  • Demo dogfood + playwright regression guard for the v1.0.2 navigation arc (#1742). The entire v1.0.2 nav arc (#1733 zero-wiring route map, #1737 SSR→hydration flash parity, #1738 DjustHooks/dj-hook) was driven by a downstream consumer because examples/demo_project didn't exercise these paths end-to-end. Adds two plain djust.LiveView pages (NavDemoPageAView / NavDemoPageBView at /demos/nav-a/ and /demos/nav-b/) linked by dj-navigate — confirming the #1733 dogfood that SPA cross-view nav needs NO live_session(), get_route_map_script(), or context-processor wiring (the route map auto-derives from the URLconf and auto-emits via {% djust_client_config %} already in the demo base <head>). Page A and Page B each carry a DemoWidget dj-hook (canvas, dj-update="ignore") registered once in the persistent shell per the #1738 pattern. New tests/playwright/test_nav_hooks.py (wired into the playwright-tests CI job) asserts: a window sentinel survives navigation and location.pathname changes (#1733 SPA nav, not a full reload); a MutationObserver on the [dj-view] root records zero direct-child remove/re-add during first-load hydration (#1737 no-flash); and the hook's mounted() marker is set on initial load AND a fresh mounted() fires on the SPA patch-inserted Page B widget (#1738 hooks-survive-nav). A future regression in any of the three paths now red-bars CI internally.
  • Zero-wiring dj-navigate — the client route map is now auto-derived from the URLconf and auto-emitted (#1733, ADR-021 Stage 1). dj-navigate previously SPA-navigated only if the developer manually wired live_session() and emitted get_route_map_script(); with no route map it silently full-reloaded. Now djust.routing.build_route_map_from_urlconf() walks the Django URLconf (descending include() resolvers) and collects every route whose callback resolves to a LiveView subclass — handling both callback.view_class and login_required-wrapped callback.__wrapped__.view_class, converting <int:id>:id, and applying the FORCE_SCRIPT_NAME sub-path prefix. The derived map is auto-emitted by {% djust_client_config %} (already in every scaffolded base <head>), with CSP-nonce support and empty-safe behavior (no <script> when an app has no LiveViews). No live_session() required. New system check djust.T016 warns when dj-navigate appears in templates but the derived route map is empty (suppressible via DJUST_CONFIG['suppress_checks']).

Changed

  • {% djust_client_config %} now takes the template context (#1733). The tag became takes_context=True so it can read request.csp_nonce for the auto-emitted route-map <script>. The Rust-engine handler delegates to the same shared helper, so dual-engine output stays byte-identical. Existing templates need no change.
  • get_route_map_script() now merges the URLconf-derived route map with live_session() entries (#1733). Behavior change: the emitted window.djust._routeMap now includes auto-derived LiveView routes in addition to any live_session() registrations (idempotent union). Apps that called get_route_map_script() with no live_session() and expected an empty map now get the URLconf-derived routes — which is the intended zero-wiring behavior. The phantom {% djust_route_map %} reference in the docstring (a tag that never existed) was removed. live_session() remains valid for WebSocket session grouping.
  • eslint warnings driven to 0 and a --max-warnings 0 ceiling re-added (#1719, follow-up to #1717). #1717 changed the eslint policy to gate on errors and tolerate warnings, which left ~33 project-wide warnings in python/djust/static/djust/*.js with no ceiling, so the count could silently grow. All 33 are now resolved: 1 prefer-const and 1 no-var (single-assignment globals), 9 no-unused-vars (8 unused caught errors → bare catch {}, 1 unused arg → _params), and 22 security/detect-object-injection — each verified a false positive (Object.keys() own-prop iteration, isSafeKey()-guarded keys, validated numeric index, fixed-literal/allowlist key, or developer-supplied component/cache-key name) and given a targeted // eslint-disable-next-line with a per-site justification (no blanket config disable; no site was a real injection risk). The --max-warnings 0 ceiling is re-added to BOTH gate paths — the .pre-commit-config.yaml eslint hook and the package.json lint script (used by the Pre-Release Security Audit workflow) — so the warning count can only go down. No src/ module changed, so the client.js/debug-panel.js bundles rebuild byte-identically; all 1665 JS tests pass.
  • CI promotes the demo djust_check dogfood to a BLOCKING demo-checks job (#1713, CI infra — completes #1708, applies CLAUDE.md rc4 retro finding #3). The dogfood added in #1708 ran as a continue-on-error step inside the non-blocking playwright-tests job, so a re-introduced dead @click / legacy attribute (the #1683 bug class) could NOT red-bar a PR. Now that the step has shipped green on the runner (the budgeted ≥1 runner-only iteration), it is extracted into a dedicated demo-checks job WITHOUT continue-on-error and wired into the test-summary aggregate gate (added to needs: and the blocking success condition alongside rust/python/js/security-tests). The dogfood step is removed from playwright-tests, which stays non-blocking and decoupled from the demo check (its own migrate step is retained for the dev server it starts). The wrapper scripts/ci_djust_check_demo.py is refactored to extract an importable evaluate(parsed_json) decision function (CLI behavior of main() unchanged), and a new unit test (tests/test_ci_djust_check_demo.py) feeds SYNTHETIC djust_check --json payloads through it to exercise BOTH gate arms end-to-end — the error-severity arm (never hit by the live 0-error demo, the Stage-11 note) and the deprecated-attr T001/T014/T015 ID-set arm — the #252 empirical canary, with a clean-payload tautology guard (#254 gate-off verified: neutering the blocking arm makes all four blocking cases fail). No framework behavior change — CI config + test only.

Performance

  • theme_context now request-scope memoizes its four tag-body renders (#1727, follow-up to #1722's Stage 11 PERF-1). #1722 made theme_context run on every WebSocket event via _apply_context_processors (rust_bridge.py). Each call re-rendered four uncached tag bodies (theme_head, theme_panel, theme_mode_toggle, theme_preset_selector) — _render_theme_outputs (CSS/switcher) was already @lru_cache'd, these four were not, so theming users paid four uncached tag renders per WS event. The four _safe_render outputs are now memoized on the request object, keyed on the resolved theme-state tuple (theme, preset, pack, mode, resolved_mode, layout, presets_key — the same shape _render_theme_outputs keys on). When theme state is unchanged across events the cache serves the strings; a live theme/mode/preset switch changes the key and recomputes, so dynamic switching is preserved (NOT first-sync-gated — CLAUDE.md v1.0.2 canon: per-event work feeding change-detection must be memoized, not skipped). Scope is request-level (not a cross-request module cache) by design: none of the four outputs currently embed per-request data (no CSP nonce; cookie_prefix_js derives from the cookie_namespace config, not the request), but request-scoped caching cannot leak a future per-request value across requests; on the WS path request is a long-lived instance attr set once in handle_connect, so the cache spans all events of a connection and invalidates on a theme switch. New regression tests in test_theme_context_memoize_1727.py (call-count fail-before/pass-after, theme-switch recompute, request isolation; #254 gate-off verified).

Security

  • Bump starlette 1.0.0 → 1.2.1 (CVE-2026-48710 host-header validation; transitive via mcp).

Fixed

  • Fixed deterministic cross-test pollution in test_checks.py (#1741). Two checks tests (TestC003DaphneOrdering::test_c003_daphne_missing_info and TestSuppressChecks::test_no_suppress_by_default) failed deterministically under the cross-dir order pytest python/djust/tests/ python/tests/test_checks.py (and intermittently in CI shards) while passing in isolation. Root cause: the block_watchdog fixture in test_dev_server_watchdog_missing.py re-imports djust.checks to exercise the no-watchdog import path (#994); a re-import rebinds both sys.modules["djust.checks"] AND the parent-package attribute djust.checks, but the fixture restored only sys.modules. The two then pointed at different module objects, so a downstream monkeypatch.setattr(checks, "_has_asgi_server", ...) patched one copy while the function under test resolved against the other — the patch silently no-op'd and C003 failed (0 == 1). The fixture now snapshots and restores the parent-package attribute alongside sys.modules. Test-only change; no runtime behavior change.
  • Initial SSR render now matches the first WebSocket frame — eliminates the first-hydration flash (#1737, completes #1724). The initial HTTP-GET render skipped the comment/whitespace normalization that render_with_diff() applies, so the server-rendered dj-root kept HTML comment nodes and as-authored inter-element whitespace while the first WS frame had them stripped. The structural mismatch made the client's first-hydration morphChildren rebuild the whole subtree (visible re-render / flash), even with #1724's client-side whitespace-only-text-node skip in place. render_full_template() now (a) falls back to matching the dj-view root when no literal dj-root attribute is present (the auto-inferred-dj-root case) so the normalized render replaces the shell root, and (b) applies _strip_comments_and_whitespace() to the rendered dj-root, mirroring the extra whitespace pass the Rust render_with_diff() performs. _strip_comments_and_whitespace() now also collapses whitespace adjacent to <pre>/<code>/<textarea> boundaries for byte-parity with the Rust pass. The SSR dj-root is now byte-equivalent to the first WS frame (modulo the dj-id attrs the client stamps onto the prerender DOM per #1610), so the first morphChildren is a no-op. <pre>/<code>/<textarea> internal whitespace and dj-if boundary markers are preserved.
  • The cross-IIFE bare-reference static guard (scripts/check-cross-iife-refs.mjs, #1706) now also catches bare references between two TOP-LEVEL bundle modules (22-51), not only guard-block→top-level references (#1716). Each top-level module wraps its body in its own inner IIFE, so a globalThis.djust-published function declared inside module A's IIFE is not visible as a bare name in module B — the same ReferenceError-under-terser class as #1676/#1688, and 10 of 58 published functions were gap-exposed. The narrow scope test (decl.inGuard && !refInGuard) is generalized to a lexical scope-barrier model: each published declaration gets a barrier span (its innermost enclosing function/IIFE body, else the double-load-guard else {} block, else null for program scope), and a bare reference is flagged iff it falls outside that span. Program-scope declarations (e.g. maybeDeferRemoval in 42-dj-remove.js, a true global visible everywhere even minified) have no barrier and are never flagged — the load-bearing false-positive control. Empirical canary (#252): a synthetic top-level publisher + bare cross-module ref exits 0 under the old code and 1 under the new code; the real tree stays clean (no new false positives), and a gate-off self-test (#254) confirms reverting the generalization makes exactly the new canary fail. New regression cases in tests/js/check-cross-iife-refs-1706.test.js.
  • The documented {% theme_X %} template tags ({% theme_panel %}, {% theme_head %}, {% theme_switcher %}, {% theme_mode_toggle %}, {% theme_preset_selector %}, and the other user-facing theme tags) now work in djust's Rust template engine — the engine that renders LiveView templates (#1721). Previously the Rust engine raised RuntimeError: Template error: Unsupported template tag '{% theme_panel %}' (a 500) for these tags even though the theming guide documents them, while only the {{ theme_panel }} context-string form (#1435) rendered — so docs and engine disagreed (cf. #1452 on {{ theme_head }} vs {% theme_head %}). The fix registers a thin TagHandler bridge for each documented theme tag via djust._rust.register_tag_handler in DjustThemingConfig.ready(); each handler delegates to the same theme_tags.py @register.simple_tag body the {{ }} form uses, so the two forms produce equivalent output for default args and the customization-with-args form ({% theme_panel show_packs=False %}) works. Python-only registration (no Rust rebuild); degrades gracefully when the Rust extension is absent, and Django-engine templates are unchanged. Performance note: like every Rust custom-tag handler, the {% theme_X %} form crosses the PyO3 boundary and runs a Python sidecar per invocation; the {{ theme_X }} context-string form pre-renders once per request and is cheaper when the same tag appears multiple times on a page. New regression tests in test_theme_tags_rust_engine_1721.py (Rust-engine fail-before/pass-after, {{ }} parity, kwargs form; gate-off verified).
  • Context-processor variables (e.g. djust theming's {{ theme_panel }} / {{ theme_head }}) now render inside the LiveView's dj-root template and its nested {% include %} partials, not just at the page top level (#1722, completes #233). Previously context processors were applied only to the outer page shell in render_full_template; the dj-root render path (render() / render_with_diff() via _sync_state_to_rust, used on the initial GET and every WebSocket update) never applied them, so a context-processor var used in the dj-root template or an include reached from it resolved to empty while plain view attributes worked. _sync_state_to_rust now applies _apply_context_processors (no-op when there is no request; view context still wins on key collisions).
  • Add inline CodeQL suppression comments for false-positive alerts in source JS: js/remote-property-injection in 03-websocket.js (server-sent view name) and debug/07-tab-state.js (null-prototype clone with UNSAFE_KEYS filter); js/xss in live_redirect path (target validated to same-origin path). Rebuilds client.js and debug-panel.js to pick up the suppressions.
  • Fix UploadWriter.write_chunk base class signature to accept optional chunk_index: int = 0 — resolves CodeQL py/inheritance/incorrect-overridden-signature (#2190); callers already guard via _supports_chunk_index introspection.
  • SSR→hydration no longer replaces the dj-view root's top-level children wholesale on the first WebSocket hydration (#1724). Root cause was whitespace text-node misalignment in morphChildren: real SSR HTML carries inter-element whitespace text nodes between sibling elements, and when the positional existing node landed on such a whitespace node, every element-matching strategy was skipped and the code fell through to clone+insert + remove-unmatched — a wholesale teardown. morphChildren now skips insignificant whitespace-only text nodes when aligning a desired element (or dj-if boundary comment) so the existing element is morphed in place. This eliminates the full visible re-render on every navigation and preserves client-side widget state mounted on those nodes (e.g. a Chart.js <canvas> no longer goes blank). Significant whitespace inside <pre>/<code>/<textarea>, dj-if comment markers, and legitimate keyed reorder/replace are unaffected.

Documentation

  • Integrating third-party JS libraries (Chart.js, maps, editors) via client hooks (#1738). Extended the Client-Side JavaScript Hooks guide with a section that leads with the inline-<script> trap — a <script> next to the element inside the reactive (dj-view) root runs on a full page reload but is silently blank after dj-navigate SPA navigation (morphed-in content's scripts don't execute; no error), the confusing "reload works, navigation doesn't" signature. Documents the canonical pattern: register the hook once in the persistent shell (window.DjustHooks.X = { mounted, updated, destroyed }), opt in via <canvas dj-hook="X" dj-update="ignore">, init the library in mounted() (fires on hydration AND SPA patch-insert), and dispose in destroyed(); explains why dj-update="ignore" keeps the VDOM from fighting the library's own DOM mutations. Cross-linked from the navigation guide's dj-navigate section as the answer to "my Chart.js chart is blank after navigation."

[1.0.1] - 2026-06-05

Added

  • New static guard scripts/check-cross-iife-refs.mjs — retires the whole #1676 cross-IIFE ReferenceError class (#1706). The "minified client crashes on a cross-IIFE symbol" class recurred three times (#1676 terser --mangle renamed cross-module applyPatchesie, fixed with --keep-fnames; #1688/#1690 bare applyPatches reference in 45-child-view.js; #1689 dup) and every prior fix was per-symbol. This adds a build-tooling lint that catches the whole class statically. The mechanism: the client bundle concatenates python/djust/static/djust/src/[0-9]*.js; modules 00-20 sit INSIDE the double-load-guard else {} block (so function foo() {} declared there is block-scoped), while modules 22-51 run at the bundle's true top level OUTSIDE that block. A bare reference from a top-level module to a guard-block function published only via globalThis.djust.X is out of scope even unminified (the typeof guard silently returns "undefined" and the feature no-ops) and throws ReferenceError under terser-minified bundles. The check reuses the check-bundle-init-order.mjs walker model (in-memory bundle build, acorn parse, line→module map), builds the djust-published function set, computes the guard else {} scope span, and flags any bare cross-scope reference (browser globals and djust.X member access are inherently never flagged; a locally re-bound name is excluded). Wired into the pre-commit hook (alongside the #1372 init-order lint) and the CI javascript-tests job (which also gains the init-order check, previously pre-commit-only). Pinned by 6 cases in tests/js/check-cross-iife-refs-1706.test.js (real-tree-clean + #1688-shape flag + empirical canary #252 + member-access-not-flagged + intra-guard-not-flagged + local-rebind-not-flagged; gate-off verified non-tautological).
  • New guide: Migrating from django-tenants → row-level djust.tenants (#1559). Schema-per-tenant (the external django-tenants library) is deprecated under djust; the new docs/website/guides/migrating-from-django-tenants.md is the step-by-step migration recipe. Covers: (1) a mental-model translation table (schema → tenant_id column; TenantMainMiddlewaredjust.tenants.middleware.TenantMiddleware; SHARED_APPS/TENANT_APPS → one unified INSTALLED_APPS; Domain model → DJUST_CONFIG['TENANT_RESOLVER']); (2) the schema-to-row data-migration recipe (nullable-add → INSERT ... SELECT per schema → tighten, with explicit handling of FK remapping, cross-tenant unique constraints → composite (tenant_id, …), sequences, and indexes); (3) code migration (add tenant_id; filter via explicit tenant_id, TenantScopedMixin.get_tenant_queryset(), or TenantQuerySet.as_manager(tenant_field=…); swap middleware); (4) settings diff (collapse the app split, swap middleware, drop DATABASE_ROUTERS, set the resolver); (5) rollout strategy (big-bang vs tenant-by-tenant, isolation verification, suppressing/stopgapping C014 during rollout); (6) what doesn't translate (hard-compliance schema isolation → engage upstream rather than silently staying on the deprecated path); and (7) a copy-pasteable cross-tenant-leak canary pytest. Every cited symbol/API is verified against the real djust.tenants modules (notably: the scoped-queryset helper is get_tenant_queryset(), not tenant_queryset). Linked from _config.yaml, index.md, and the Multi-Tenant guide.
  • New T015 system check — detects the legacy data-djust-root / data-djust-view root attributes (#1602). Pre-1.0 templates declared the LiveView root with data-djust-root / data-djust-view; djust 1.0 renamed these to dj-root / dj-view (the data- prefix is no longer required). When a template still uses the old spelling, the generic T012 ("dj-* directives but no dj-view") doesn't recognise that a view IS declared — so the path from symptom (the LiveView never connects over WebSocket) to fix is non-obvious. T015 scans user template files and emits a Warning that names the rename explicitly, with a fix_hint per offending occurrence (file:line). The match is scoped via a negative-lookahead to exactly data-djust-root / data-djust-view, so other data-djust-* attributes (data-djust-embedded, data-djust-activity, data-djust-view-model, …) never false-match. Suppressible via DJUST_CONFIG = {"suppress_checks": ["T015"]}. Scope: static check only — the runtime does not accept the legacy attributes (a separate change). New cases in TestT015LegacyRootAttrs (empirical-canary + gate-off verified; dogfooded clean against the demo project).
  • DJUST_NOTIFY_DATABASE_URL — optional dedicated DSN for the djust.db LISTEN connection (#1687). db.notifications._build_dsn() previously always derived the long-lived LISTEN AsyncConnection DSN from settings.DATABASES['default'], so the listener could not be isolated from the request-path connection pool (downstream djustlive #380: pgbouncer session-pool saturation → /health hangs). A new optional DJUST_NOTIFY_DATABASE_URL setting (also honored as an environment variable of the same name) supplies a DATABASE_URL-style override (postgres://user:pass@host:port/dbname) that is preferred BEFORE the DATABASES['default'] fallback — point it at a direct, session-mode Postgres endpoint so the listener can't saturate a shared transaction-pool. Backwards-compatible: when unset, the produced DSN is byte-identical to prior releases. The postgres-only engine check still applies to the override (a non-postgresql URL scheme raises DatabaseNotificationNotSupported), and the override URL/password is never logged. New _dsn_from_url() helper parses the URL via urllib.parse (no new dependency). Pinned by 7 cases in TestBuildDsnOverride (gate-off verified).
  • DJUST_NOTIFY_DATABASE_URL now honors a known-safe libpq query-param allowlist (#1696, follow-up to #1687). db.notifications._dsn_from_url() previously parsed scheme/user/password/host/port/dbname from the override URL but silently DROPPED the query string — so the two most common direct-to-Postgres LISTEN needs, ?sslmode=require (TLS) and the unix-socket form ?host=/var/run/postgresql, were impossible to express. The parser now appends an explicit allowlist of libpq connection parameters from the query string to the produced DSN: sslmode, sslrootcert, sslcert, sslkey, host, application_name, connect_timeout. Values are percent-decoded consistently with the userinfo fields and libpq-quoted ('…' with backslash-escaping) when they contain whitespace. host precedence: a ?host= query item REPLACES the URL netloc host (so the output carries exactly one host key — the deterministic unix-socket behavior; the netloc host becomes an ignored placeholder). Credential safety: unknown query keys are silently dropped, and user/password/dbname are deliberately NOT in the allowlist, so a query string can never override the URL-derived credentials. Backwards-compatible: a no-query URL produces a DSN byte-identical to the #1695 output. Still uses urllib.parse only (no new dependency); the URL/DSN/password is never logged. New cases in TestDsnQueryParams (gate-off verified).

Changed

  • CI now dogfoods djust_check against the demo project (#1708, CI infra — enforces CLAUDE.md #1060). #1683 shipped dead @click buttons to the 1.0 GA demo even though the T001 system check existed — because the demo templates were never run through djust_check in CI. A new step in the playwright-tests job runs scripts/ci_djust_check_demo.py, a wrapper around manage.py djust_check --json. The wrapper is necessary because djust_check itself ALWAYS exits 0 (handle() only prints results — no exit-code logic), so a bare invocation can never fail CI. The wrapper parses the JSON summary and exits non-zero ONLY on error-severity checks and the deprecated-attribute classes T001/T014/T015 (the exact #1683 bug class) — NOT on the demo's intentional warnings (S005 public-view-without-auth, T012 partial-fragment templates, V004 informational). Empirically verified: re-introducing a single @click= into a scratch demo template makes the step report T001 and exit 1; the clean demo exits 0. The step inherits the job's continue-on-error: true, so it is NON-BLOCKING on its first runner iterations (CLAUDE.md rc4 retro finding #3: a new CI check exercising an env the dev machine can't fully mirror needs ≥1 runner-only iteration budgeted); promote it to a blocking gate once it has shipped green on the runner. No framework behavior change — CI config only.
  • scripts/check-doc-snippets.py now scans docs/website/guides/*.md for symbol/import resolvability (#1707, CI infra — extends the #1500 guard). The checker previously validated only README.md + QUICKSTART.md, so guide prose could drift from the real API with no CI guard — exactly how #1559/#1699 shipped ~10 hallucinated djust.tenants symbols undetected. The part-(a) check (AST-parse + import/symbol resolution) now also runs over all 57 guides; parts (b) (Django-floor / JS-size claims) and (c) (security/style lint) stay README/QUICKSTART-specific (guides legitimately use print() in demo examples, so the style verdict is out of scope). New --guides-dir / --no-guides flags (guides scanned by default; an explicit missing --guides-dir is a usage error, exit 2). Wired into CI (test.yml) and the pre-commit hook (its files: scope now includes the guides dir). Survey of the current tree surfaced 10 part-(a) flags across 9 guides: 3 real wrong-import-path fixes (djust_themingdjust.theming in components.md; djust.live_view.statedjust.decorators.state in state-primitives.md; djust.uploads.storesdjust.uploads.storage in uploads.md) and 6 intentionally-illustrative blocks (external celery, placeholder yourapp.models, list-indented fragments, an API-doc signature stub) marked with the existing <!-- doc-snippet-check: skip --> directive — no guide needed a follow-up rewrite. Also fixed a resolver false-positive (from X import submodule, e.g. from django.db import migrations, now falls back to importing the dotted submodule before declaring the symbol missing). New cases in TestCheckGuides (gate-off + submodule-fallback regression, empirical-canary verified: re-introducing from djust.tenants import tenant_queryset makes the checker exit 1 and name the symbol). No framework behavior change — CI/docs only.
  • C014's hint and fix_hint now link the new django-tenants migration guide (#1559). The check (django-tenants + ASGI without TENANT_LIMIT_SET_CALLS) already led with the migrate-to-djust.tenants recommendation and the strategy-decision guide (multi-tenant.md); both the hint and fix_hint now also point at docs/website/guides/migrating-from-django-tenants.md for the step-by-step recipe. No logic change — same trigger conditions, same suppression (DJUST_CONFIG = {'suppress_checks': ['C014']}), and all existing multi-tenant.md / djust.tenants / TENANT_LIMIT_SET_CALLS hint substrings preserved.
  • CI lint cleanup + de-noised Pre-Release Security Audit + eslint now gates on errors (#1717, CI/lint hygiene — no framework behavior change). Three coordinated cleanups: (1) Lint fixes (all pre-existing): 5 clippy style warnings rewritten behavior-neutrally — sort_by(|a,b| …cmp…)sort_by_key(…) in crates/djust_vdom/src/patch.rs (descending removes via std::cmp::Reverse, ascending inserts plain) and crates/djust_vdom/src/lib.rs (two descending offset sorts via Reverse), and a collapsible nested if folded into the match arm guard in crates/djust_templates/src/parser.rs. Two eslint errors resolved: the redundant 'use strict' inside the IIFE-module js/pwa.js is removed (rule strict), and the XSS-sanitizer denylist match val.startsWith('javascript:') in security.js carries an explanatory // eslint-disable-next-line no-script-url (the check itself is unchanged — no-script-url was a false positive flagging a denylist MATCH, not a script-URL USE). (2) De-noise CI: .github/workflows/pre-release-security-audit.yml now sets workflow-level CARGO_TERM_COLOR: never + NO_COLOR: "1" so cargo/clippy/cargo-audit/eslint emit no raw ANSI escapes, and each verbose scan step's console output is wrapped in ::group::/::endgroup:: (collapsed by default in the Actions UI); the FULL detail still flows into the uploaded *-report.md artifacts. (3) Gate eslint on errors: the JS-scan eslint step dropped || true for set -o pipefail + captured-exit, so a real severity-2 regression (e.g. an XSS / no-script-url error) now FAILS the step while warnings stay non-fatal (eslint exits non-zero only on errors by default); the report artifact is still written. The pre-commit eslint hook is aligned to the same policy (dropped --max-warnings 0). Verified: cargo clippy --all-targets -- -W clippy::all -W clippy::complexity -D clippy::correctness -D clippy::suspicious → 0 warnings; npm run lint → 0 errors (33 warnings surfaced, non-fatal); djust_vdom/djust_templates Rust tests green (sort/patch ordering preserved); a synthetic severity-2 eslint error makes npm run lint exit non-zero (gating demonstrated, then removed).
  • Fixed the Pre-Release Security Audit's "Create tracking issue" step (CI-internal; no framework change). Two pre-existing bugs, surfaced by a manual workflow_dispatch: (1) the guard (inputs.create_issue == true || inputs.create_issue == '') ran the step even when create_issue=false was passed — GitHub Actions coerces a boolean false and '' both to 0, so false == '' is true; dropped the == '' clause (inputs.create_issue == true is correct, push is already excluded by the event guard). (2) The issue body (audit template + full scan summary) had no length cap and exceeded GitHub's 65536-char issue limit → HTTP 422; it is now truncated to 65000 with a pointer to the security-audit-report artifact, which always carries the full detail. The scans themselves were unaffected (all green); only issue-creation failed.

Fixed

  • Multi-tenant guide (docs/website/guides/multi-tenant.md) no longer documents non-existent djust.tenants symbols (#1699). The guide cited several APIs that do not exist, so copy-pasted examples would ImportError/AttributeError. Corrected three error classes plus follow-on inaccuracies, all verified against the real API (python/djust/tenants/mixin.py, resolvers.py): (1) self.tenant_queryset(...)self.get_tenant_queryset(model=None) (mixin.py:214); (2) from djust.tenants.mixins import ...from djust.tenants import ... (module is mixin, singular); (3) DJUST_TENANT_RESOLVER = 'djust.tenants.resolvers.XResolver' (a non-existent top-level setting whose value was a class path) → DJUST_CONFIG = {'TENANT_RESOLVER': '<short-name>'} where the value is a RESOLVER_REGISTRY key ('subdomain'/'path'/'header'/'session'/'custom', or a list for chained resolution); the per-strategy DJUST_TENANT_CONFIG nested dicts were folded into flat DJUST_CONFIG keys (TENANT_MAIN_DOMAIN, TENANT_SUBDOMAIN_EXCLUDE, TENANT_PATH_POSITION, TENANT_HEADER, TENANT_SESSION_KEY, TENANT_CUSTOM_RESOLVER, TENANT_DEFAULT). Also fixed the API-reference table (tenant_get_object_or_404/tenant_filter → real get_tenant_object/create_for_tenant), the Testing section (which imported a non-existent djust.tenants.test module — rewritten to use set_current_tenant + TenantInfo, mirroring the verified migrating-from-django-tenants.md guide), and TenantInfo(id=...)TenantInfo(tenant_id=...) (the real first positional kwarg). Docs-only; no framework behavior change. Verified: grep confirms zero old forms remain; every cited symbol/module/config key import-checks against the real djust.tenants API.
  • Demo/example apps no longer ship dead @click/@input/@change/@submit handler bindings; migrated to dj-* (#1683). The shipped client (09-event-binding.js) binds dj-* ONLY — the @event= form is deprecated (T001 system check) AND non-functional, so demo controls authored with it rendered as inert "dead buttons." Migrated 117 handler bindings → dj-* across 32 files: examples/demo_project/ (djust_demos, demo_app, djust_homepage, djust_shared — inline-HTML strings in .py views/demo-classes plus the 3 demo .md translation guides), top-level examples/rust_components_demo.py / examples/range_component_demo.py, and framework docstrings/comments (live_tags.py, websocket.py, websocket_utils.py, validation.py). The Alpine.js dropdown component (python/djust/components/ui/dropdown_simple.py@click="open = !open" with x-data/x-show) is a client-side JS expression, NOT a djust handler, and is intentionally preserved unchanged. Demo-only change; no framework behavior change. Verified by grep (zero in-scope @event= bindings remain) and manage.py djust_check (no T001/@click findings for migrated views).
  • Runtime SafeString from a custom filter is no longer over-escaped inside {% firstof %} / {% cycle %} (#1672, follow-up to #1660). #1660 threaded runtime-safeness through the {{ var|filter }} Variable and InlineIf render arms, but the parallel get_value pipe helper (used by the {% firstof a|md %} / {% cycle a|md ... %} emit path) applied filters via the plain apply_filter_full, dropping the runtime-safe flag. A custom filter that mark_safe()s its output AT RUNTIME (without @register.filter(is_safe=True)) was therefore double-escaped in those tags — e.g. <em>Hi</em> rendered as &lt;em&gt;Hi&lt;/em&gt;. This was fail-safe over-escaping, NOT an XSS — a parity gap, not a security hole. Fix: a new get_value_safe returns (Value, bool runtime_safe), threading the safe flag out of the pipe loop via apply_filter_full_safe (mirroring the per-iteration runtime_safe = produced_safe pattern from the #1660 Variable arm); the FirstOf/Cycle emit arms skip auto-escaping when the value is a genuine runtime SafeString. get_value is preserved as a thin wrapper so its other callers are untouched. The fix is strictly additive (only ever marks MORE values safe, only when the last filter produced a real str-subclass with __html__), so it can never under-escape a plain value. Pinned by 7 regression cases in TestFirstofCycleRuntimeSafe_1672 (gate-off verified) plus parallel-path-drift code comments per CLAUDE.md #1646.
  • {% firstof x|safe %} / {% cycle x|urlize %} no longer over-escape the output of NAME-based safe filters (#1692, completes the #1660→#1672 lineage). #1672 threaded RUNTIME mark_safe()-ness through the {% firstof %} / {% cycle %} emit path via get_value_safe, but that helper did not consult the name-based safe_output_filters whitelist (safe, safeseq, force_escape, json_script, urlize, urlizetrunc, unordered_list) that the {{ var|filter }} Variable render arm uses. So a chain ending in one of those filters — e.g. {% firstof x|safe %} or {% cycle x|urlize %} (where urlize emits its own <a href=…> HTML) — was still double-escaped in those two tags. Fix: get_value_safe's filter loop now also marks the value safe when the applied filter NAME is in the whitelist (or is a custom is_safe=True filter), mirroring the Variable/InlineIf arms exactly. The whitelist was hoisted from two inline copies into a single shared module const SAFE_OUTPUT_FILTERS so all three render paths reference one source of truth (parallel-path-drift, CLAUDE.md #1646). Fail-safe, like #1672: it only ever ADDS safeness for the established whitelisted names / genuine runtime SafeStrings; a plain/unknown filter (e.g. upper) stays escaped, and LAST-filter re-taint semantics are preserved ({% firstof x|safe|upper %} re-escapes). Pinned by 4 Rust cases in renderer::tests (test_firstof_safe_filter_not_double_escaped, test_cycle_urlize_filter_not_double_escaped, test_firstof_nonsafe_filter_still_escaped, test_firstof_safe_then_plain_filter_re_taints) + 4 Python cases in tests/unit/test_rust_firstof_cycle_named_safe_1692.py (gate-off verified).
  • client.min.js no longer logs Uncaught ReferenceError: applyPatches is not defined in production (#1688). A recurrence of the #1676 terser-mangle × IIFE class, different manifestation. 45-child-view.js referenced the bare applyPatches symbol at two sites (_applyScopedPatches and the djust._applyPatches expose block), but applyPatches is declared inside 12-vdom-patch.js's own inner IIFE and published only as globalThis.djust.applyPatches. The bare cross-IIFE reference is out of scope: it silently no-ops in the unminified bundle (leaving djust._applyPatches unwired, so emitChildMountedEvents — the child-mounted lifecycle for embedded/sticky views — never runs) and throws in the terser-minified production bundle (served when DEBUG=False), logging an alarming uncaught error in every console at page load. Non-fatal — core LiveView (WebSocket connect, event dispatch, DOM patching via the in-scope applier) keeps working. Fix: read the published alias globalThis.djust.applyPatches at both sites, which is minification-independent and also restores the intended _applyPatches wiring. Pinned by a behavioral regression (tests/js/min_bundle_applypatches_1688.test.js) asserting djust._applyPatches is wired after load (gate-off verified: undefined on the pre-fix bundle).
  • dj-dialog-close-event (35-dj-dialog.js) and keyboard-nav dj-click dispatch (51-keyboard-nav.js) no longer reference a bare out-of-scope handleEvent (#1706). Found by the new cross-IIFE static guard (above): both modules referenced the bare handleEvent symbol, which is declared in 11-event-handler.js inside the double-load-guard else {} block (block-scoped) and published only as globalThis.djust.handleEvent. Since both modules run at the bundle's true top level (OUTSIDE the guard block), the bare reference was out of scope even unminified — the typeof handleEvent === 'function' guard returned "undefined", so the dialog close event and the keyboard-nav dj-click activation silently no-opped — and would throw ReferenceError under terser-minified bundles. Exactly the #1688 class, two more sites. Fix: read the published alias globalThis.djust.handleEvent at all four sites (minification-independent). The dj_dialog / keyboard_nav test stubs were updated to spy on the alias (production's actual invoke path) rather than the stale bare global.
  • Broke the latent registry ↔ theme_packs / registry ↔ manifest import SCC in djust.theming (#1662, follow-up to #1661). After #1661 extracted get_theme_config to the leaf _config, the AST import graph (lazy + eager) still had a pre-existing, never-CodeQL-flagged strongly-connected component: theme_packs/manifest imported registry.get_registry while registry imported theme_packs/manifest for discovery. Fix (same leaf-module pattern as #1661): extract ThemeRegistry + get_registry into a new leaf module _registry_accessor.py (imports only stdlib) so theme_packs / manifest / presets / etc. reach the singleton WITHOUT importing back into registry; discovery (the only registry → theme_packs/manifest edges) stays in registry.py and is installed as a hook via set_discovery_hook, making the dependency one-directional. registry.py re-exports ThemeRegistry / get_registry / register_* so from djust.theming.registry import … keeps working — no runtime, behavior, or public-API change. test_theming_no_cyclic_import.py is tightened to assert the WHOLE djust.theming package import graph is acyclic (Tarjan over all modules, counting both from .X import and from . import X edges) plus a leaf-purity gate — previously only presets/manager/css_generator were gated and the registry SCC was explicitly allowed. Gate-off verified: the tightened test fails on pre-fix code with SCC [manifest, registry, theme_packs].

[1.0.0] - 2026-06-02

First stable release. 🎉

Promotes v1.0.0rc18 to general availability with no code changes since rc18 — the public API is now SemVer-stable under the deprecation policy documented in docs/API_STABILITY.md. The 1.0.0 stabilization cycle ran from rc1 (2026-05-17) through rc18 (2026-06-01): eighteen release candidates of hardening on real production sites (djust.org and djustlive run on djust; every example app dogfoods it). See the rc1rc18 entries below for the full changelog of the stabilization cycle.

[1.0.0rc18] - 2026-06-01

Fixed

  • {% kanban_board %} card moves no longer storm html_recovery on a tabbed dashboard (#1678, fully fixed). This was a stacked bug (reopened 3×); both layers are now resolved:
    • Layer 1 — markers stripped from client HTML. _strip_comments_and_whitespace (hydrated-mount + SSE + html_recovery egress) removed all comments via re.sub(r"<!--.*?-->", "", …), including the <!--dj-if …--> / <!--/dj-if--> boundary markers — load-bearing VDOM structure the Rust parser counts as significant children and the client differ resolves patch paths against. The client DOM lost every dj-if marker while the server's last_vdom kept them. Fixed with a negative-lookahead regex (<!--(?!\s*/?dj-if\b).*?-->) that strips ordinary comments but preserves dj-if markers. Pinned by python/tests/test_djif_marker_preservation_1678.py.
    • Layer 2 (root cause) — client patch-ordering for nested boundaries. When a tab activates whose body is a nested conditional (the live shape: {% if active_tab=="ideas" %}{% if has_ideas %}{% kanban_board %}{% else %}{% empty_state %}{% endif %}{% endif %}), the differ emits MoveSubtree(outer boundary) + InsertSubtree(inner boundary) with final-structure indices. The client applied InsertSubtree (phase −1) before MoveSubtree (phase 3), so the inner span landed as a sibling of the outer boundary instead of nested inside it — the client's flat marker tree then diverged from the server's by one significant child, so the subsequent positional count SetText landed on a dj-if comment marker → html_recovery on every drag. With flat indices no single linear phase order satisfies #1370 (Insert-before-path), #1666 (Move-after-path) and #1678 (Insert-after-Move); the fix puts InsertSubtree + MoveSubtree in one boundary-span phase after the child ops, applied in ascending target index, so a moved outer boundary is repositioned before a nested insert lands inside it (src/12-vdom-patch.js _sortPatches + _applyPatchesInner). The #1370 corruption guard (RemoveSubtree before RemoveChild) is preserved. Verified by a new client-faithful differential harness (tests/js/vdom_client_faithful_diff.test.js) that replays real server patches through the actual client.js in jsdom and compares the full significant-child tree (incl. markers) to a fresh render — reproduced deterministically on both a synthetic fixture and the real captured djust_pm sequence, both green after the fix; tests/js/patch_sorting.test.js pins the ordering.

[1.0.0rc17] - 2026-05-31

Fixed

  • Rapid events on a self-broadcasting view no longer storm full-HTML recovery + reconnect (#1677). When a view's event handler calls push_to_view() for its own view (the djust new scaffold's react/vote/post_message demos), the triggering session received both its direct event response (VDOM version N) and its own self-broadcast (N+1, N+2…). The client tracks a single VDOM version with a strict sequential check, so under rapid bursts the interleaved non-sequential versions read as corruption → a request_html full-HTML recovery on every gap (a re-fetch + morph storm), with an occasional location.reload()/WS reconnect when the socket hiccupped mid-storm. The originating session already has the state from its direct response, so the self-broadcast is redundant: push_to_view now tags broadcasts with the originating channel (a ContextVar set during event handling) and server_push skips a broadcast whose sender_channel matches the receiving session — leaving the delicate client version-check untouched. Broadcasts to other sessions and external pushes (Celery, cross-view; sender_channel is None) are unaffected. Semantic note: a handler's push_to_view to its own view no longer echoes back to the triggering session — set state directly on self for local updates (the normal pattern). Pinned by tests/unit/test_push_self_broadcast_1677.py (gate-off verified).

  • {% kanban_board %} no longer fails VDOM patching on every card move (#1678). The tag rendered cards and columns as positional HTML with no dj-id/dj-key anchors. Since it wires drag-and-drop to a move_event, the natural usage is a stateful LiveView that re-renders after a card changes columns — but that shifts per-column child counts, so the VDOM differ patched against stale positional node paths: ~half the patches failed and every drag paid a full html_recovery morph + extra round-trip. Cards now carry dj-key="<card id>" and columns dj-key="<col id>" (the keyed-reconciliation anchor → VNode.key), so the differ reconciles by identity across the move instead of by position. Pinned by python/djust/components/tests/test_kanban_djkey_1678.py.

  • client.min.js no longer crashes with ReferenceError: ie is not defined in production (#1676). P0: after the #1635 single-IIFE wrap, terser --mangle renamed a cross-module function declaration (applyPatchesie) and relocated it so a caller's await ie(...) no longer resolved — crashing the minified client (served when DEBUG=False) and triggering a WebSocket reconnect loop. The unminified client.js resolves the reference via the shared outer-IIFE scope, so the bug was production-only and invisible to the (unminified-only) unit suite. Fix: scripts/build-client.sh now passes terser --keep-fnames, preserving function names through mangling so no cross-module reference can be renamed out of scope. This is terser-version-independent and covers the whole class of cross-module functions (not just applyPatches). Costs ~2 KB gzipped (correctness over size for a prod-breaking class). Guarded by a structural check (tests/js/min_bundle_applypatches_1676.test.js) asserting the minified bundle preserves the names; gate-off verified (drop --keep-fnamesapplyPatches mangles to ie → the guard fails). Reproduction note: the crash is browser-runtime/terser-version specific and does not reproduce under jsdom/Node V8, so the fix rests on the reporter's bit-exact prod console evidence + structural verification.

[1.0.0rc16] - 2026-05-29

Added

  • MoveSubtree VDOM patch — matched {% if %} boundaries can now be repositioned (#1666). When a dj-if conditional boundary is present in both the old and new render but its position among the parent's significant children shifts (siblings added/removed/reordered around it), the markers — id-less #comment nodes that a plain MoveChild can't target — previously stayed anchored at their old position, so the conditional rendered in the wrong place (or the round-trip diverged). The differ now emits MoveSubtree { id, path, d, index } (the "move" verb for boundary spans, completing the Remove/Insert/Move trio), and the client (12-vdom-patch.js) locates the <!--dj-if id="X"-->...<!--/dj-if--> marker pair by id, detaches the whole range, and re-inserts it at the target index — preserving inner node identity (and any state/focus tied to inner dj-ids), unlike a Remove+Insert. Applied in a new phase after the path/index child ops so the surrounding siblings have settled. A client-faithful differential harness measured the matched-boundary-reposition residual drop from ~22 to ~6 per 6000 adversarial re-renders. Wire shape pinned in wire_protocol_snapshot.rs; behavior pinned by matched_djif_boundary_repositioned_via_move_subtree (Rust) and tests/js/move_subtree_patch.test.js (client).

  • LiveViewTestClient.assert_allowlisted() + assert_all_routed_liveviews_allowlisted() — make the allowlist gap testable (#1674). A URL-routed LiveView forgotten from LIVEVIEW_ALLOWED_MODULES has its WebSocket mount rejected and silently degrades to full-page HTTP re-renders — but mount() instantiates the view class directly, bypassing the allowlist, so the misconfiguration was invisible to the unit suite. client.assert_allowlisted() fails fast for one view; the standalone assert_all_routed_liveviews_allowlisted() walks the root URLconf and guards every routed view in a single test. Both mirror the runtime enforcement exactly (non-empty allowlist, prefix match) and are no-ops when the allowlist is unset/empty.

Fixed

  • VDOM InsertChild.ref_d no longer mis-inserts under sibling reorder. The differ populated ref_d with a positional guess (old.get(new_index).djust_id) — the dj-id of whatever old node happened to sit at the new node's index. The client (12-vdom-patch.js) honors ref_d (it does insertBefore(node, querySelector(':scope > [dj-id=ref_d]')) and only falls back to the index when ref_d is absent), so under a reorder that guess is the wrong reference and the new node lands in the wrong position. The differ now emits ref_d: None and relies on the index: InsertChild is applied last in the client's phase order (after RemoveSubtree/InsertSubtree/RemoveChild/MoveChild), so the index is resolved against the settled new-frame and is reliable. A client-faithful differential harness measured ~18 mis-inserts per 6000 adversarial re-renders before the fix, 0 after. The #1408 invariant is preserved (a ref_d, when present, must resolve in the OLD tree). Regression-pinned by keyed_insert_ref_d_is_safe_not_a_wrong_guess in crates/djust_vdom/tests/test_diff_robustness_gaps.rs.

  • Custom template filter that mark_safe()s its output at runtime is no longer HTML-escaped (#1660). The Rust renderer decided auto-escaping purely by filter name (the static is_safe=True flag / the built-in safe_output_filters list), never by the filter result's runtime SafeString-ness — unlike Django's render_value_in_context, which escapes iff the final value lacks __html__. So a filter like @register.filter def md(v): return mark_safe(...) (no is_safe=True) had its HTML escaped on both the HTTP-prerender and WS-mount paths. apply_custom_filter now reports whether the Python result carries __html__; apply_filter_full_safe threads that out as a per-filter runtime-safe flag; and the renderer (Variable + InlineIf arms) honours the last filter's runtime safeness in its escape decision (additive — a later plain-returning filter re-taints, matching Django). Security-hardened: the runtime-safe marker is honoured only for a genuine str subclass with __html__ (a real SafeString) — Django stringifies any non-str value before the __html__ check, and djust's Value extraction stringifies arbitrary objects via __str__, so trusting __html__ alone would let a non-str object's attacker-controlled __str__ reach output unescaped (XSS). Workaround no longer needed: @register.filter(is_safe=True) and pre-render+|safe still work. Pinned by the runtime-SafeString matrix (text/attribute context, x|md|upper re-taint, x|upper|md, plain-filter-still-escaped) and the non-str-__html__ impostor XSS guard in tests/unit/test_rust_custom_filters_1121.py; gate-off self-test (Action #1200/#1468) + a 5-lens adversarial XSS verification confirmed non-tautological. The parallel {% firstof %}/{% cycle %} get_value pipe path still over-escapes a runtime-safe value (fail-safe, not XSS) — tracked in #1672.

  • Unallowlisted URL-routed LiveView no longer degrades silently — actionable signal at ship-time and runtime (#1674). A URL-routed LiveView whose module is missing from LIVEVIEW_ALLOWED_MODULES has its WebSocket mount correctly rejected, then silently falls back to full-page HTTP re-renders that look like the app works (events fire, DB updates, page re-renders) — a DX trap, not a security/correctness bug. Two fixes: (1) the djust.V005 system check now also discovers URL-routed views by walking the root URLconf (_routed_liveview_classes), not only __subclasses__() (which is import-timing dependent), so the misconfiguration is caught at manage.py check/ship-time; V005's matching was aligned with the WebSocket mount enforcement (websocket.py) — prefix match, enforced only when the allowlist is non-empty — fixing a pre-existing false positive where an empty [] flagged every view and a prefix allowlist (['myapp']) wrongly flagged myapp.views (parallel-path-drift, CLAUDE.md #1646). (2) The client's HTTP-fallback notice (11-event-handler.js), previously a djustDebug-gated console.log (silent by default), is now a once-per-session, un-gated console.warn pointing the developer at LIVEVIEW_ALLOWED_MODULES. Pinned by python/djust/tests/test_v005_routed_allowlist_1674.py, test_assert_allowlisted_1674.py, and tests/js/http_fallback_warning_1674.test.js; dogfooded against the demo (1 real finding, no flood).

Tests

  • Apply-level regression pin for the #1636 {% if %} 1/N patch failure. #1636's InsertChild 1/N patches failed (a false→true {% if %} add) was a manifestation of #1640 (the index resolver getSignificantChildren and the path walker getNodeByPath disagreeing on whether a regular HTML comment counts) and was confirmed fixed on rc14 by the consumer. #1640 shipped a predicate-level test (in the significant_children_comment_filter_1640 suite); this adds the missing applied-patch-level pin for the chronically-reopened if-block cluster (#1358/#1408/#1550/#1552/#1555/#1636): an InsertChild whose index points past a regular <!-- comment --> lands between the right significant siblings, not one slot early (the 1/N). Gate-off proof (Action #1200/#1468): flipping isSignificantChild back to counting all comments and rebuilding makes the mid-insert case fail (the inserted node lands at significant index 0 instead of 1). 3 regression cases in tests/js/insert_child_regular_comment_1636.test.js. No source change — guards an already-shipped fix.

[1.0.0rc15] - 2026-05-28

Fixed

  • @permission_required event handlers no longer raise SynchronousOnlyOperation (#1648). Sibling of #1638: the per-event handler-permission check (_validate_event_security) called check_handler_permission synchronously from an async def. For a handler decorated with @permission_required, that calls user.has_perms(), which under Django's default ModelBackend queries the DB for a non-superuser — raising SynchronousOnlyOperation in the event loop (and, unlike the object-permission path, with no fail-closed catch, so it propagated). The call is now wrapped in sync_to_async, mirroring #1638 and the mount path.
  • live_redirect to a different view now mounts the correct target (#1647). The client's resolveViewPath() falls back to the current container's dj-view — the source view — when window.djust._routeMap is empty, which is the default for apps using plain Django path() URLconfs (no live_session()). The server trusted that client-supplied class in the live_redirect_mount frame, instantiated the source view against the destination URL's request, and raised "Failed to load view. Please refresh the page." handle_live_redirect_mount now resolves the destination view server-side from the URL via Django's URL dispatcher and overrides the client-supplied view when the URL maps to a djust LiveView (falling back to the client value otherwise, so live_session() route maps are unaffected). live_session() is no longer a hidden prerequisite for live_redirect across plain path() URLconfs.

[1.0.0rc14] - 2026-05-28

Added

  • LiveViewTestClient.assert_http_ws_djid_parity() — test-infra harness for HTTP-GET vs WebSocket-mount dj-id parity (#1642). Builds two independent instances of a view (one exercising the HTTP initial-page path via render_full_template then render_with_diff; one the WebSocket-mount baseline via render_with_diff only) and asserts they assign an identical dj-id baseline in the dj-root subtree. A divergence is the getNodeByPath → null first-event patch-miss shape investigated in #1641; this locks the #1370 "marker IDs match between the initial HTTP DOM and subsequent WS diffs" invariant against regression and lets the divergence hypothesis be tested framework-side. See docs/website/guides/testing.md.

Fixed

  • djust new --with-db projects now create their tables on deploy (#1637). The scaffold ran migrate --run-syncdb but never makemigrations, so a --with-db app shipped with no migrations. --run-syncdb created the tables on the developer's machine (masking the gap), but a deploy runs migrate without --run-syncdb, so the app's tables were never created and the first request 500'd (no such table: <app>_<model>). djust new now runs makemigrations before migrate (generating the app's migrations/__init__.py + 0001_initial.py, which ship in the deploy artifact), and explicitly writes <app>/migrations/__init__.py for model-bearing apps so the migrations directory is a real package — not a namespace package the migration loader silently skips — even when --no-install is used.

  • client.js no longer throws a re-declaration SyntaxError on live_redirect / bfcache restore (#1635). live_redirect keeps the document alive and morphdom can re-attach the <script src="client.js"> tag; a bfcache pageshow re-init follows the same path. Two classic <script> executions share one global lexical environment, so top-level const/let/class in the bundle re-declared at parse time on the second execution — Identifier '_TEXT_INPUT_TYPES' has already been declared — before the runtime window._djustClientLoaded guard could run. The bundle is now wrapped in a single IIFE (scripts/build-client.sh), so every top-level declaration is function-scoped; a second execution builds a fresh scope and the runtime guard short-circuits the re-init. Public API is still exported via explicit window.* / globalThis.djust.* assignments inside the IIFE — no observable change.

  • VDOM index patches no longer mis-resolve when a template has a plain HTML comment among siblings (#1640). The client's getSignificantChildren counted ALL comment nodes, while getNodeByPath (path traversal) and the Rust VDOM parser count only dj-if-family boundary markers and drop every other comment. Index-based patches (InsertChild/RemoveChild/MoveChild) resolve their index via getSignificantChildren, so a regular <!-- comment --> among siblings shifted the client index one slot off the server's — inserting/removing/moving the wrong node. getSignificantChildren now uses the same dj-if-only predicate as getNodeByPath. dj-if markers are still counted (server parity); regular comments are dropped.

  • Per-event object-permission check no longer false-denies on the first event (#1638). The per-event re-check (_validate_event_security) called check_object_permission synchronously from an async def, while the mount path wraps it in sync_to_async. The developer's sync get_object() — the canonical ADR-017 pattern doing Model.objects.get(...) — then raised SynchronousOnlyOperation in the event loop, and the fail-closed except translated it into a spurious "Access denied" permission_denied frame on the first event of every URL-bound LiveView with a sync get_object(). The per-event call is now wrapped in sync_to_async, mirroring the mount path. Who's affected: any LiveView following the documented ADR-017 object-permission pattern (get_object() doing a sync ORM read) on djust ≥ v0.9.5-1b. No migration needed — the fix is transparent; the false denial simply stops.

  • live_redirect() / live_patch() from a state-unchanging handler now reach the client (#1643). When an event handler reassigns no public attr (e.g. it only writes to a DB model, then calls live_redirect()), handle_event takes its auto-skip-render branch and returns via _send_noop. That branch — and the batched _dispatch_single_event skip branch, plus the server_push and db_notify broadcast paths — flushed only push_events/flash/page_metadata/pending_layout/deferred, silently dropping queued navigation / accessibility / i18n commands. The result: the handler ran (DB write succeeded) but the browser URL never changed. Root cause was duplication — the turn-end flush sequence was hand-copied across ~10 sites with inconsistent subsets. Consolidated every turn-end path onto a single _flush_all_pending() helper that flushes all queued side-effects in canonical order (draining is idempotent, so paths that also go through _send_update are unaffected). Net reduction of ~29 lines in websocket.py; also closes the same latent gap on the server_push and db_notify broadcast paths.

  • start_async completion now refreshes the VDOM recovery baseline (#1636). When a client VDOM patch fails (e.g. an {% if %} block that adds a sibling), the client requests html_recovery and the server serves — then clears — _recovery_html (one-time use). LiveViewConsumer._run_async_work re-renders and sends patches when a start_async background callback completes, but — unlike handle_event and server_push (the #1202 fix) — it never updated _recovery_html / _recovery_version. After a recovery had consumed the baseline, an async-triggered patch that also failed on the client found _recovery_html=None, got back "Recovery HTML unavailable", and the view froze at the transitional state (e.g. Status: fetching) even though the backend pipeline had advanced. _run_async_work now sets the recovery baseline after render_with_diff() in both the patches branch and the full-HTML fallback branch, mirroring the other two render paths, so async-callback state pushes keep reaching the client across patch-failure/recovery cycles.

[1.0.0rc13] - 2026-05-28

Added

  • dj_button accepts a confirm="" kwarg (#1621). When non-empty, emits the standard dj-confirm="<message>" attribute consumed by djust's client.js (python/djust/static/djust/src/09-event-binding.js:7) — clicking the button shows a JS confirm() dialog with the message; on OK the event fires, on Cancel nothing happens. Closes a small DX gap where users wanting the dialog had to either skip dj_button (losing the theme integration / variant→class mapping from #1619) or wrap the tag in ad-hoc JS. The underlying dj-confirm primitive was already wired in client.js across multiple directives (dj-click, dj-submit, etc.); this PR just exposes it through the component tag. Emission is independent of event= value — the attribute is useful on event-less buttons users have wired up via other directives. conditional_escape neutralizes XSS surface, matching the existing event attr escaping. Preset override supported via the existing preset-priority pattern (preset values fill defaults; explicit kwargs win). Discovered building djust-org/djust-start's reset-demo button on djust 1.0.0rc12. 5 new regression cases in python/djust/components/tests/test_dj_button_confirm_1621.py; gate-the-fix-off self-test (Action #1200/#1468) passes.

  • PresenceMixin.online_count instance attribute — zero-config {{ online_count }} template binding (#1611). PresenceMixin now auto-maintains self.online_count (an integer count of presences in the group) inside track_presence, untrack_presence, _restore_presence, and the new _on_presence_change broadcast handler. Templates can use {{ online_count }} with zero scaffolding — no get_context_data override required. The attribute is set on the view instance (NOT via get_context_data) because djust's diff dirty-tracking watches instance attribute mutations, and a value that only lives in the context dict doesn't trigger patches. Discovered building djust-org/djust-start on djust 1.0.0rc7. Behavior change: NEW public attribute on PresenceMixin users. Existing code that already does self.online_count = ... will be transparently overwritten by the mixin's auto-set; rename your attribute if you need different semantics.

  • PresenceMixin.presence_unique_per_connection: bool = False opt-in flag for anonymous-tab uniqueness (#1613). By default, two browser tabs of the same anonymous user share a Django session and therefore one anon_<session_key> user_id — the presence count stays at "1 online" no matter how many tabs are open. This is correct for an authenticated user collaborating with themselves but wrong for a demo. When presence_unique_per_connection = True, anonymous users get anon_conn_<ws_session_id> derived from the per-WebSocket-connection ephemeral UUID instead, so each tab counts as a distinct presence. Authenticated users always use request.user.id regardless of the flag — logged-in tab collapse is intentional. Behavior change: NEW class-level opt-in flag. Existing apps unchanged (False default).

  • PresenceMixin._on_presence_change default @event_handler for auto-broadcast fanout (#1614). track_presence and untrack_presence now fire a push_to_view broadcast to a well-known handler name _on_presence_change on the view class. PresenceMixin ships a default @event_handler-decorated _on_presence_change that refreshes self.online_count on the receiving session. Body is exclusively self._refresh_online_count() — explicitly does NOT call track_presence, so the broadcast loop terminates after one hop. Subclasses may override but should call super()._on_presence_change(**kwargs) to preserve count refresh. Behavior change: every track_presence / untrack_presence call now sends one extra channel-layer broadcast. No regression on existing apps; the broadcast is no-op for any session whose view class doesn't have an _on_presence_change listener.

Fixed

  • C003 now accepts uvicorn or hypercorn as an ASGI server (#1630). The check previously fired an INFO whenever daphne was missing from INSTALLED_APPS, telling users to pip install daphne. But djust's canonical recommendation has been uvicorn since the README rewrite (uvicorn myproject.asgi:application) and djust-org/djust-start ships uvicorn by default — every uvicorn-based project was forced to ship a permanent "C003" in DJUST_CONFIG['suppress_checks'] to silence guidance that pointed at the non-recommended server. New _has_asgi_server() helper probes daphne/uvicorn/hypercorn via importlib.util.find_spec (no actual import, can't ImportError-break manage.py check); C003 only fires when none is installed, and the hint now points at uvicorn as the canonical pick. The existing daphne-ordering branch (when daphne IS in INSTALLED_APPS but after staticfiles) is unchanged. New cases in TestC003AsgiServers1630 cover uvicorn-installed/hypercorn-installed/daphne-via-find_spec/no-server-at-all + helper unit tests. Updated test_no_suppress_by_default to stub _has_asgi_server per Action #1200 so the suppression contract is isolated from the broadening. Impact on djust-start: suppress_checks drops from ["C003", "T002"] to ["T002"].

  • Broke the four-module presets/registry/manager/css_generator cyclic-import SCC in djust.theming (CodeQL alerts #2352/#2351/#1900/#1883). Extracted the built-in theme imports + the THEME_PRESETS registry dict into a new python/djust/theming/_builtin_presets.py leaf module. registry._do_discover now imports THEME_PRESETS from _builtin_presets (not presets), and manager.py / css_generator.py defer their get_preset / get_theme_config imports to call sites and pull ThemePreset / ThemeTokens annotations from the leaf _types module. _builtin_presets has no runtime dependency on presets/registry/manager/css_generator, so the back-edge that closed every cycle in the SCC is gone. Back-compat preserved: presets.py re-exports the named *_THEME constants via from ._builtin_presets import * so external code doing from djust.theming.presets import BLUE_THEME keeps working, and the djust.theming.__init__ public surface is unchanged. 7 new AST-based regression cases in python/djust/tests/test_theming_no_cyclic_import.py pin every broken edge so a future refactor can't silently re-introduce the cycle; gate-the-fix-off self-test (Action #1200/#1468) confirmed each is non-tautological.

Changed

  • PresenceMixin.track_presence() now no-ops during the HTTP-prerender phase (#1612). Every djust LiveView's mount(request, **kwargs) runs twice per page load — once for HTTP prerendering and again when the WebSocket connects. Each run creates a separate view instance. Previously, code that called track_presence() in mount() registered the connection twice; the HTTP-mount entry was an orphan that lingered for PRESENCE_TIMEOUT (~60s) until stale cleanup. After this PR, track_presence early-returns when _websocket_session_id is absent (set only by LiveViewConsumer on the WS path), so presence registers only once. Reporter's existing if hasattr(self, "_websocket_session_id"): workaround is now built-in. Behavior change: anyone calling track_presence() outside the WebSocket lifecycle (unit tests, management commands) will see a silent skip; the new reproducer tests + downstream fixture updates set _websocket_session_id = "test_ws" to opt back in.

[1.0.0rc12] - 2026-05-27

Added

  • LiveView.abstract: bool = False class-attribute marker — opt out abstract base classes from per-class V/Q system checks (#1605). A common pattern is to define an abstract BaseLiveView(LiveView) that subclasses extend for shared mount/auth boilerplate. The base typically has no template_name and is never mounted directly, but V001 (missing template_name) and V005 (not in LIVEVIEW_ALLOWED_MODULES) still fired on it because the per-class check loop in python/djust/checks.py:check_liveviews had no abstract opt-out. The new abstract = True class attribute mirrors Django's Meta.abstract semantics: setting it on a subclass skips that class's per-class V/Q checks (V001/V005/V002/V003/V004/V007/Q007), and the marker is consulted via cls.__dict__.get("abstract") so it is NOT inherited — subclasses of an abstract base are still validated as concrete unless they redeclare abstract = True themselves. Both the abstract opt-out and the global suppress_checks mechanism (see ### Fixed below) work; choose abstract when the intent is "this specific class is boilerplate" and suppress_checks when the intent is "this check is the wrong shape for our codebase." Documented in docs/system-checks.md (new "Abstract base LiveView classes" section) and docs/guides/error-codes.md (V001 + V005 entries). Behavior change: NEW public API — LiveView gains an abstract: bool = False class attribute. Existing user code sees no change unless it opts in.

Fixed

  • V008 no longer false-fires on stdlib module functions like inspect.getsource, os.path.join, json.dumps, Path.read_text, datetime.isoformat (#1628). Follow-up to #1609/#1623. The bare-builtin fix in #1623 missed qualified calls because _get_call_name returns the dotted name (inspect.getsource) for attribute-access calls — those didn't match the bare-name SAFE_TYPES entries. Fix extends SAFE_TYPES with the cited qualified names: inspect.getsource/getsourcefile/getmodule/getdoc, os.path.join/basename/dirname/exists/isfile/isdir/abspath/relpath, os.getenv/getcwd, pathlib.Path.read_text/exists/is_file/is_dir, json.dumps, datetime.datetime.isoformat, datetime.date.isoformat. Also adds two bare method names (isoformat, read_text) for chained-call forms like Path(p).read_text() and datetime.now().isoformat() where _get_call_name returns just the method name. Bare exists/is_file/is_dir are intentionally NOT added (too ambiguous with user code — some_record.exists() etc.); use the qualified pathlib.Path.exists(p) form, os.path.exists(str(p)), or # noqa: V008. Reporter's alternative (annotation-based trust — -> str return annotation) is deferred — would require resolving imports + inspecting target module annotations at static-check time. Discovered building djust-org/djust-start on djust 1.0.0rc12. After this lands, the starter has zero local accommodations for framework quirks. 10 new regression cases in python/tests/test_checks_v008_stdlib_qualified_1628.py; 34 V008 tests green total (10 new + 9 from #1623 + 15 pre-existing). Gate-the-fix-off self-test (Action #1200/#1468) passes.

  • {% code_block %} now syntax-highlights code blocks inserted via djust WS patches (#1625). The per-instance inline <script> that lazy-loads highlight.js worked on initial HTTP page load but failed for any <code> element that arrived via a WS patch — modern browsers don't execute scripts inserted via innerHTML/DOM manipulation, so the inline highlight bootstrap never ran for re-inserted code blocks (they appeared plain-text). Fix installs a MutationObserver ONCE per page (gated by window.__djcHljsObserverInstalled) that watches document.body for added <pre><code class="language-*"> elements and highlights any unmarked ones via hljs.highlightElement. The observer is installed on each of the three hljs-ready paths in the existing bootstrap (already-loaded, first-load s.onload, parallel-load poll), so it lives wherever the bootstrap can reach. Per-instance inline scripts still run on initial HTTP page load — the observer is purely additive. Feature-detected via typeof MutationObserver === 'undefined' so very old browsers gracefully fall through. highlight=False path unchanged. 6 new regression cases in python/djust/components/tests/test_code_block_observer_1625.py (source-text gates pin the install + scope + selector + idempotency flag). Discovered building djust-org/djust-start on djust 1.0.0rc12 — companion to #1624.

  • {% theme_head %} now auto-loads djust-components's components.css when djust.components is in INSTALLED_APPS (#1624). Previously theme_head (from djust-theming) loaded only djust-theming's own components.css. djust-components ships a separate components.css at python/djust/components/static/djust_components/components.css with layout rules for {% code_block %}, {% card %}, {% dj_button %} spinners, etc. — but theme_head didn't link it, so components rendered in user templates fell back to default flow layout (looked broken). Fix detects djust.components via django.apps.apps.is_installed("djust.components") in build_theme_head_context and adds a conditional <link> next to the existing djust-theming link in theme_head.html. Detection is defensive — apps.is_installed() raises if the app registry isn't populated yet, so the call is wrapped in try/except and falls back to no link. Without djust.components installed, theme_head emits no extra link (graceful degradation, zero behavior change for users not on djust-components). 6 new regression cases in python/djust/tests/test_theme_head_components_link_1624.py; the #1123-style pre-mount/post-mount keyset invariant test (TestThemeMixinThemeHead::test_build_theme_head_context_keyset) updated to include the new context key. Discovered building djust-org/djust-start on djust 1.0.0rc12. Gate-the-fix-off self-test (Action #1200/#1468) passes.

  • V008 no longer false-fires on stdlib primitive-returning builtins (#1609). V008 (Non-primitive type assigned to self.X in mount()) inspected the bare call name against a SAFE_TYPES set that contained type-constructor names (list, dict, str, int, ...) but missed stdlib builtins that always return primitives. Result: self.online_count = max(1, len(...)) triggered the warning even though max(int, int) returns an int. Fix extends SAFE_TYPES with numeric builtins (max, min, sum, abs, round, pow, divmod, len, ord, hash, id), string-conversion builtins (bin, oct, hex, repr, chr, ascii, format), sorted (returns list, same element-serializability trust contract as list()), and frozenset / bytes (overlooked scalar/container primitives). Iterator-returning builtins (reversed, enumerate, zip, map, filter, range, iter) intentionally remain flagged — they return iterator/generator objects that aren't directly JSON-serializable when stored on a view; the user must materialize via list() first. complex and slice also remain flagged. The V006 (Warning) path is untouched. Discovered building djust-org/djust-start on djust 1.0.0rc7. 9 new regression cases in python/tests/test_checks_v008_builtins_1609.py; 15 existing V008 tests at python/tests/test_checks.py::TestV008NonPrimitiveInMount continue to pass unchanged. Gate-the-fix-off self-test (Action #1200/#1468) passes.

  • dj_button(variant="danger") now renders styled (#1619). dj_button previously produced class="btn btn-danger" unconditionally from the variant keyword, but djust-theming's components.css (loaded by theme_head) only ships rules for .btn-primary, .btn-secondary, .btn-destructive, .btn-ghost, and .btn-link — so variant="danger", variant="success", and variant="warning" rendered with class names that had no matching CSS rule. (scaffold.css DOES have .btn-danger/.btn-success rules but is not loaded by theme_head.) Fix introduces a _DJ_BUTTON_VARIANT_CLASS_MAP in python/djust/components/templatetags/djust_components.py mapping keyword variants to the canonical CSS class names; danger is now an alias for destructive (matching shadcn/Tailwind convention). Variants not in the map (including the now-deprecated success/warning keywords, plus user-defined custom variants) pass through as btn-<variant> via conditional_escape, preserving the existing security boundary and enabling user theme classes. The danger keyword alias keeps back-compat with existing templates (e.g., python/djust/components/gallery/examples.py:158). Docstring updated to list the 5 supported variants. Discovered building djust-org/djust-start on djust 1.0.0rc12. 5 new regression cases in python/djust/components/tests/test_dj_button_variant_1619.py (danger alias, destructive canonical, primary unchanged, unknown passthrough, XSS-escape preserved); gate-the-fix-off self-test (Action #1200/#1468) passes.

  • Render diff misrouted SetText patches when a template variable was adjacent to literal text (#1617). build_fragment_text_map (crates/djust_live/src/lib.rs:2597-2633) mapped each rendered fragment to the first VDOM text node whose content equalled the fragment. For {{ online_count }} online, the variable's rendered fragment ("1") doesn't equal the chip's full text content ("1 online"), so the matcher fell through to a sibling text node whose content happened to equal "1" (typically a bare reaction count). When the variable changed, the SetText patch landed on the wrong node — chip stayed at "1 online" forever while the unrelated reaction count visually became "2" (state still said 1). Fix maps each fragment by its byte position in the assembled HTML to the text node whose HTML range contains it, claiming the entry only when the fragment IS the entire text node (full-coverage check). Ambiguous cases (partial-overlap, whitespace-only fragments, fragments containing tags) fall through to the byte-level text_region_fast_path, which is already sound for this scenario. Bug class: any {{ var }}<literal>, <literal>{{ var }}, or {{ a }}{{ b }} template pattern. The reporter's state= works / handler= broken framing was refuted by code inspection: both push_to_view paths converge at _sync_state_to_rustset_changed_keysrender_with_diff and take the same text_fast_path; the fix addresses the root cause. Discovered building djust-org/djust-start on djust 1.0.0rc12. 3 new regression cases in python/djust/tests/test_text_fast_path_misroute_1617.py (bug repro, adjacent {{a}}{{b}}, pure-case regression backstop); 6 existing #1529 content-collapse regression cases still pass; wire-protocol invariants (Actions #1448/#1538/#1541) preserved — SetText struct + msgpack/JSON serialization unchanged. Gate-the-fix-off self-test (Action #1200/#1468) passes: reverting to content-equality matching reproduces the misroute, restoring the position-aware body makes it pass.

  • WS-mount HTML now properly applied to pre-rendered DOM (#1610). When the client signaled has_prerendered=true in the WS mount message, the server's WS-mount HTML was previously used ONLY to stamp dj-id attributes onto the existing pre-render DOM (_stampDjIds(data.html) at python/djust/static/djust/src/03-websocket.js:361). Any state that diverged between HTTP-prerender and WS-mount context — presence counts, _websocket_session_id-derived values, anything that only resolved in the WebSocket scope — was silently dropped, and the DOM stayed at the prerender values until a subsequent broadcast happened to mutate something else. Fix calls morphChildren (the same helper used by handleEmbeddedUpdate at 03-websocket.js:1127 and the html_recovery path at 03-websocket.js:641) to diff the pre-render DOM against the WS-mount HTML and apply the differences. morphChildren preserves keyed nodes by id, so the dj-id stamp step is folded into the morph. PR #1615's track_presence/untrack_presence auto-broadcast partially masked this bug for the specific online_count case (the broadcast synthesized a patch frame post-mount); this fix closes the general bug class for non-presence WS-context state. Discovered building djust-org/djust-start on djust 1.0.0rc7. 8 new JS regression cases in tests/js/ws-mount-prerender-divergence-1610.test.js (source-text gate + JSDOM live tests + sticky-exclusion + missing-container fallback) plus 3 server-side correctness pins in python/djust/tests/test_ws_mount_prerender_divergence_1610.py. Gate-the-fix-off self-test (Action #1200/#1468) passes: reverting the morphChildren call to _stampDjIds(data.html) makes the JSDOM live tests fail, restoring it makes them pass.

  • DJUST_CONFIG = {"suppress_checks": [...]} now silences V002, V003, V004, V007, and Q007 (#1607). Direct mechanical follow-up to #1604 — the same wiring oversight, on five additional check IDs that share the per-class loop in python/djust/checks.py::check_liveviews. V002 (no mount() method), V003 (wrong mount() signature), V004 (handler-like name without @event_handler), V007 (event handler missing **kwargs), and Q007 (overlapping static_assignstemporary_assigns) all emitted warnings without consulting the project-wide _is_check_suppressed() helper. With this PR every V/C/T/Y/Q emission site inside the per-class loop now honors the global DJUST_CONFIG['suppress_checks'] shortcut; the per-class abstract = True opt-out from #1605 already covered abstract classes but the global-by-ID escape hatch was missing. 10 regression cases in python/tests/test_checks_1607_suppress.py (suppress + regression per ID); gate-the-fix-off self-test (Action #1200/#1468) passes for each — reverting an individual guard makes the corresponding suppress test fail, restoring makes it pass.

  • DJUST_CONFIG = {"suppress_checks": ["V001", "V005"]} now silences V001 and V005 (#1604). V001 (python/djust/checks.py:1215-1244) and V005 (python/djust/checks.py:1382-1393) emitted warnings without consulting the project-wide _is_check_suppressed() helper that every other V/C/T/Y check (C003, C013, C014, C303, V008, V010, V011, Y001-4, T002, T012, ...) already honored. Result: the documented escape hatch DJUST_CONFIG = {"suppress_checks": ["V001", "V005"]} was silently a no-op for V001/V005 even though it worked for C003 (the original reporter's confusion). Fix wraps both emission sites with _is_check_suppressed("djust.V001") / _is_check_suppressed("djust.V005") guards matching the existing pattern. Discovered while building the djust-org/djust-start starter template, which ships a BaseLiveView pattern that hits both #1604 and #1605. Reporter's SILENCED_SYSTEM_CHECKS = ["djust.V001", "djust.V005"] workaround (Django's own mechanism) still works. Hint text for both checks updated to mention all three escape hatches (abstract = True, DJUST_CONFIG['suppress_checks'], and SILENCED_SYSTEM_CHECKS). 9 regression cases in python/tests/test_checks_1604_1605.py lock both fixes in (4 suppression cases, 4 abstract cases including non-inheritance and explicit-False, 1 base-class declaration check); gate-the-fix-off self-test (Action #1200/#1468) passes — reverting the V001 guard makes test_v001_suppressed_via_djust_config fail, restoring it makes it pass.

[1.0.0rc11] - 2026-05-24

Fixed

  • Cross-origin dj-navigate / live_redirect / live_patch no longer throws SecurityError (#1599). Reported from production djust.org: clicking <a dj-navigate="https://djustlive.com/"> (or any cross-origin URL) crashed the JS runtime with Uncaught SecurityError: Failed to execute 'pushState' on 'History': A history state object with URL 'https://djustlive.com/' cannot be created in a document with origin 'https://djust.org'. Root cause: handleLiveRedirect() and handleLivePatch() in python/djust/static/djust/src/18-navigation.js both build newUrl = new URL(data.path, window.location.origin) — when data.path is an absolute URL, the URL() base argument is ignored, so newUrl becomes the cross-origin URL. The subsequent pushState then triggers the browser's same-origin policy (history API forbids cross-origin pushState). Fix: detect newUrl.origin !== window.location.origin BEFORE the pushState call in both handlers; on cross-origin, fall back to window.location.href = newUrl.toString() which performs a full-page navigation — the caller's intent — and avoids the crash. Preserves all same-origin behavior unchanged. _executePatch() (the dj-patch click handler) was incidentally safe because it builds newUrl from window.location.href and only overwrites pathname when patchValue starts with / — but a defense-in-depth grep for this pattern in nav-touching code is a candidate Stage 11 reviewer check (filed as candidate action-tracker rule). Covered by 3 new regression cases in tests/js/navigation.test.js under the issue #1599 — cross-origin pushState guard describe block: handleLiveRedirect with cross-origin path does NOT call pushState, handleLivePatch with cross-origin path does NOT call pushState, and same-origin paths still use pushState (regression backstop for guard breadth). Gate-the-fix-off self-test (Action #1200/#1468) passes: stripping both cross-origin guards from the source, exactly 2 of 24 navigation tests fail (the cross-origin cases) — the same-origin backstop + all 21 pre-existing tests continue to pass.

[1.0.0rc10] - 2026-05-24

Tests

  • Theming register/get roundtrip meta-invariant (#1597) — structural prevention for the #1595 bug class. PR #1596 fixed presets.get_preset() to consult the runtime registry first (the #1595 bug); this PR adds the meta-invariant test that catches the bug class — any future register_X() API in djust.theming.registry whose matching module-level get_X() ignores the registry. New file python/djust/tests/test_theming_register_get_roundtrip_invariant.py carries an authoritative ROUNDTRIP_PAIRS table covering the 3 current pairs (register_preset → presets.get_preset, register_design_system → theme_packs.get_design_system, register_theme_pack → theme_packs.get_theme_pack); a parametrized test asserts the roundtrip works for each (register sentinel → call module-level getter → assert identity); a second test_no_unaudited_register_apis_1597 AUDIT test grep-walks djust.theming.registry's public surface and fails loud if any register_* function exists without a row in ROUNDTRIP_PAIRS — so a new register API can't ship without either coverage or an explicit decision to opt it out. Failure messages name the specific module/function that broke the contract and pointer-reference the canonical fix pattern (theme_packs.get_theme_pack() at python/djust/theming/theme_packs.py:1216). Gate-the-fix-off self-test (Action #1200/#1468) passes: reverting get_preset to its pre-#1596 broken shape, exactly 1 of the 4 new test cases fails (the register_preset→get_preset case) — the other 3 parametrized cases + the audit case continue to pass, confirming no tautology and that each parametrized case is independently load-bearing. Generalizes PR #1565's "read/write gate symmetry" rule (action-tracker candidate) to the lookup-API-pair class for registry-aware subsystems.

Fixed

  • Theming registry/static-dict divergence — runtime-registered presets now reach the CSS generator (#1595). register_preset() adds to a runtime Registry._presets dict that the theme manager, theme switcher, and introspection APIs all consult; but presets.get_preset() — the function the CSS generator path ultimately calls to render --primary etc. into :root — read only from the static THEME_PRESETS module dict, blind to runtime registration. Result: any consumer following the documented register_preset() API in AppConfig.ready() got their custom palette silently replaced with the default slate-black THEME_PRESETS["default"] in the actual rendered CSS, while the manager/switcher/gh-pr-checks-style introspection correctly reported the registered preset as active — exactly the kind of API-says-X-but-renderer-uses-Y divergence that costs an hour of debugging. Fix mirrors the registry-first-OR-static-fallback dispatch already established in theme_packs.get_theme_pack() (python/djust/theming/theme_packs.py:1216-1222): get_preset() now consults get_registry().get_preset(name) first, then falls back to THEME_PRESETS.get(name, DEFAULT_THEME). Same shape, same import .registry inside the function to avoid the circular import that bites if registry imports back from presets. Covered by 3 new regression tests in python/djust/tests/test_theming_presets.py (test_get_preset_consults_runtime_registry_first_1595 + 2 companions locking in the second half of the contract: static-dict fallback for built-in names + DEFAULT_THEME fallback for unknown names). Gate-the-fix-off self-test passes (Action #1200/#1468): with the fix reverted, exactly 1 of the 3 new tests fails — the registry-first regression case — and the other 2 pass, confirming no tautology. Removes the need for the documented workaround (_presets.THEME_PRESETS[name] = preset) — consumers can now use the public register_preset() API alone.

[1.0.0rc9] - 2026-05-22

Fixed

  • Wizard {% if/elif %} step-leak (#1552) — root cause fixed at the WS-mount LOAD path. Bisect on the reporter's consumer (NYC Claims wizard) narrowed the regression window to 0.9.7rc1 (GOOD) → 0.9.7rc2 (BAD), with PR #1466 / commit a5e2c50c (feat(websocket): persist LiveView state on WS event for reconnect continuity) as the single feature commit in that window. PR #1466 changed handle_mount's LOAD gate from if has_prerendered: to if has_prerendered or saved_state: AND made the request.session.aget(view_key, {}) read itself unconditional — every view got its previously-saved session state restored on every WS mount, including views that never opted in via enable_state_snapshot. For non-opt-in views, that restoration ran AFTER mount() had initialized the view; the next render_with_diff() then diffed against a baseline clobbered by the session-restored state, producing patches that reference dj-ids that don't correspond to the client's actual DOM. The applier's querySelector(':scope > [dj-id=X]') failed, fell back to index resolution, and removed the wrong node after InsertSubtree had shifted positions — the old wizard-step subtree survived in the DOM. Visible symptom: the #1552 wizard step-leak. PR #1478 (commit 066d7f05, closing issue #1475) later added a SAVE-side gate on enable_state_snapshot, fixing snapshot-on-idle write amplification but leaving the LOAD path unconditional — so the client-DOM-mismatch bug introduced by PR #1466 survived through 0.9.7rc3, 0.9.7 final, and all 1.0.0 rcs through rc8. The fix gates the saved_state read in handle_mount symmetrically with PR #1478's SAVE-block gate: saved_state = await request.session.aget(view_key, {}) if request.session and getattr(self.view_instance, "enable_state_snapshot", False) else {}. Behavior preserved for opt-in views (enable_state_snapshot = True) — PR #1466's reconnect-resume capability still fires for them. Behavior restored to 0.9.7rc1 for default views. Verified end-to-end on the consumer (#1552 reporter's NYC Claims wizard, djust==1.0.0rc8 + this patch): step 1 → 2 → 3 → back transitions all produce exactly one h2.card-title in the DOM, no leak. PR #1466's 10 reconnect tests (python/djust/tests/test_ws_reconnect_state_1465.py) preserved unchanged for opt-in coverage; the test_load_gate_loosened_fires_on_saved_state_without_has_prerendered assertion was strengthened to require BOTH request.session AND enable_state_snapshot in the gate (passes Action #1200/#1468 gate-off self-test). First PR in the #1552 saga to satisfy the multi-reopen rule (Action #1389 / PR #1086 precedent) via bit-exact end-to-end verification on the reporter's exact environment; PRs #1553 (test-pinning), #1555 (dj-id counter fix — adjacent issue), and #1564 (framework-pin investigation) worked at framework-synthetic shapes and could not reproduce the user-visible symptom because the bug lived in the LOAD path that those reproducers exercised correctly.

[1.0.0rc8] - 2026-05-22

Tests

  • Framework-level invariant pin for #1552 {% if/elif %} + {% include %} swap (3 cases, all PASS on main). The #1552 reporter verified the user-visible bug (post-swap DOM contains BOTH step subtrees) still reproduces on rc7 even after PR #1555's dj-id counter fix. Investigation in this PR confirmed: at the framework level, with the bit-exact template shapes the reporter described, the differ produces correct Remove+Insert patches — including the full {% extends %} + {% block %} + {% if/elif %} + {% include %} inheritance shape. The user-visible bug must live in another layer (WS save block / sticky-child persistence / JS patch application / unsampled interaction); pursuing it requires reporter-side data, not more synthetic-shape framework theorizing (per CLAUDE.md Bug-report triage rule #1 and the multi-reopen rule #1389 / PR #1086 precedent). The 3 new framework-pin tests in python/tests/test_if_elif_include_swap_framework_pin_1552.py (test_framework_include_swap_emits_correct_remove_then_insert_1552, test_diagnostic_patch_op_summary_1552_include_swap, test_framework_include_swap_with_extends_and_block_1552) lock in the framework's current correctness at these shapes — if a future change regresses any of them, the tests catch it fast. The #1552 issue stays OPEN and will receive a follow-up comment requesting the reporter share a BugCapture URL (iter A feature from PR #1563, shipped the same day) capturing state_before + state_after + vdom_patches from the moment of the broken transition; comparing their actual patches against the framework reproducers will identify the divergent layer.

Added

  • djust.bug_capture — share a broken djust transition via a URL fragment a teammate can paste back to reproduce, no source-tree access required (B7 iter A, refs #1552; v1.1.0 Path D). Promotes B7 (Time-travel sharable URLs) from "killer demo idea" in the v1.1 readiness session to a load-bearing v1.1 capability, triggered by the #1552 reporter's upstream-bug-velocity data point ("the gap between 'I see it broken' and 'you can see it broken' is the full source tree"). The v1.1 readiness session recommended Path E (defer until launch-soak data exists) with the hedge "refuse to commit before data exists"; the #1552 filing supplied that data. Iter A (this release) ships the foundation: a BugCapture dataclass holding the 3 minimal fields needed to reproduce a broken transition (state_before, state_after, vdom_patches), an encode() / decode() URL-fragment round-trip using a versioned djbug1.<base64-urlsafe> wire format, a scrub hook with a ready-made scrub_fields(*names) helper for PII redaction, a wire-visible scrubbed_fields list (names only, never values) so reviewers know what was held back, and an encode_view_state(view, scrub=...) convenience that pulls the latest event snapshot + VDOM patches from a view with time_travel_enabled = True. Security model is load-bearing: the module docstring leads with a 3-paragraph "READ THIS BEFORE USING" warning; encoded blobs may contain user PII and are NOT authenticated; encode() raises RuntimeError in production (DEBUG=False) unless the deployer explicitly opts in via DJUST_BUG_CAPTURE_PROD_OPT_IN = True (literal True only, not truthy — defensive against typo-enable); the wire format is JSON + base64-urlsafe, never pickle, and a regression test pins this; the decoder treats all input as untrusted (validates types, requires fields, rejects malformed base64 with validate=True against the urlsafe alphabet, rejects malformed JSON, rejects non-object payload, rejects unknown outer version AND mismatched inner "v" field). Iter B (read-only replay viewer at /__djust__/replay/<blob> + share button in the debug panel) and iter C (Redis snapshot store + djust replay CLI + framework-level LiveView.time_travel_excluded_fields class attribute with djust check V012 enforcement) are tracked as separate v1.1.0 issues #1561 and #1562. New python/djust/bug_capture.py module; new docs page docs/website/guides/bug-capture.md linked from _config.yaml and index.md. Framework integration trade-off: encode_view_state() takes patches as an explicit required parameter (the caller obtains them from view.render_with_diff() and passes in). The original sketch read view._last_vdom_patches / view._last_patches, but PR #1563's Stage 11 reviewer correctly caught (Action #1101) that no framework code actually writes those attributes — render_with_diff() returns patches directly into the WS/SSE/runtime frame paths without stashing them on the view. Iter B (#1561) will add a debug-panel button that calls render_with_diff() + encode_view_state() in one click, eliminating the caller burden. scrub_fields() scopes to top-level keys only (documented as such) — nested paths like state["user"]["password"] need a custom callable; iter C (#1562) will add framework-level time_travel_excluded_fields declarative scrub. Covered by 36 regression cases in python/djust/tests/test_bug_capture.py across 5 test classes (TestRoundTrip 6, TestScrub 6, TestDebugGate 4, TestUntrustedInput 10, TestEncodeViewState 10 — the EncodeViewState class grew by 2 after the Stage 11 fix-pass: test_raises_on_malformed_patches_json and test_raises_on_patches_wrong_type pin the new _coerce_patches boundary), including a gate-off self-test (#254 / #1468) confirming 2 of 4 DEBUG-gate tests fail without the _enforce_prod_gate() call (the other 2 are intentionally tautology-safe: prod-opt-in-allowed exercises the bypass path; decode-regardless tests decode, which is gate-independent).

Deprecated

  • django-tenants (schema-per-tenant) integration is now deprecated as a multi-tenancy strategy for djust applications (follow-up to #1556). djust ships its own row-level multi-tenancy in djust.tenants (subdomain/path/header/session resolvers + TenantMixin / TenantScopedMixin + tenant-scoped state backends + presence isolation), and this is the supported and recommended path going forward. The external django-tenants library implements schema-per-tenant isolation via SET search_path on every request, which is a documented production footgun under ASGI + LiveView — every WebSocket event (tick_interval, push_to_view, presence, @notify_on_save) re-enters TenantMainMiddleware and issues a Postgres roundtrip, exhausting the connection pool under sustained load (#1556 was the prod 503 incident that motivated this deprecation). The djust.tenants row-level path does not have this failure mode by construction (no SET search_path in the per-event path). Existing django-tenants integrations continue to work, but no new ASGI-correctness or LiveView integration work will be done on that path; new applications should not adopt it. A dedicated migration guide is tracked as #1559 for v1.1.0, covering the schema-to-row data migration, code/middleware swap, and rollout strategy. The djust.C014 system check (also in this release; see ### Added below) is the in-product breadcrumb pointing existing django-tenants users at the deprecation + migration. Reflected in docs/website/guides/multi-tenant.md (the "Choosing Your Multi-Tenancy Strategy" section explicitly marks django-tenants as deprecated under djust and frames TENANT_LIMIT_SET_CALLS = True as a stopgap, not a fix). Behavior change: framework-level — none. Documentation/messaging change: substantial.

Changed

  • djust.C014 hint and docs/website/guides/multi-tenant.md upgraded from soft "consider djust.tenants" framing to hard deprecation framing for django-tenants (follow-up to #1556). The first cut of C014 (shipped in this release; see ### Added) described django-tenants as one of two viable strategies. After the deprecation decision (see ### Deprecated above), the messaging now leads with migration as the recommended path and treats TENANT_LIMIT_SET_CALLS = True as a stopgap rather than a long-term fix. Specific changes: C014's primary warning message now explicitly flags django-tenants as deprecated (visible in manage.py check output without expanding hints); C014's hint leads with migration to djust.tenants + link to the strategy guide, then describes the TENANT_LIMIT_SET_CALLS = True stopgap; C014's fix_hint reorders to lead with migration and labels the django-tenants config path as a stopgap. The multi-tenant guide's "Choosing Your Multi-Tenancy Strategy" section is rewritten to mark the django-tenants subsection as > **Deprecated.**, lists why (production footgun + scope mismatch with djust's mixins), points at the migration tracking issue, and presents the stopgap settings explicitly inside a "stopgap only; migrate to djust.tenants for long-term support" boundary. Covered by 5 new/updated hint-quality test cases in python/djust/tests/test_c014_multi_tenant_asgi.py::TestC014HintQuality (16 total, up from 11): hint mentions djust.tenants, hint links the strategy guide, hint marks django-tenants deprecated, fix_hint leads with migration and treats the flag as stopgap, and the Warning message itself surfaces the deprecation.

Added

  • New system check djust.C014 — flag django-tenants integration as deprecated and warn when the stopgap TENANT_LIMIT_SET_CALLS = True is missing (#1556). Surfaces both the deprecation (see ### Deprecated above) and the misconfiguration that caused a production 503 on djustlive: under ASGI + django-tenants, every WebSocket event re-enters TenantMainMiddlewareset_tenant()SET search_path. LiveView amplifies this — tick_interval polling, push_to_view re-mounts, presence updates, and @notify_on_save listener re-mounts each re-enter the middleware. Without TENANT_LIMIT_SET_CALLS = True, every re-entry issues a fresh Postgres roundtrip; under load the Postgres pool exhausts and pods serve 503 simultaneously. The check fires when ALL of these hold: (1) django_tenants is in INSTALLED_APPS OR TENANT_MODEL is set, (2) ASGI_APPLICATION is set, (3) TENANT_LIMIT_SET_CALLS is unset or False. Emits a DjustWarning whose primary message explicitly flags django-tenants as deprecated; the hint leads with the migration recommendation (link to docs/website/guides/multi-tenant.md and tracking issue #1559) and describes TENANT_LIMIT_SET_CALLS = True as the stopgap; the fix_hint follows the same order. Suppressible via DJUST_CONFIG = {'suppress_checks': ['C014']}. The framework-level safety improvement for users still on django-tenants during the migration window — caching the tenant per WS session at LiveView mount time (option a from #1556) — is tracked separately in #1557 (security-review label) for v1.1.0. New helper _check_multi_tenant_asgi_set_calls in python/djust/checks.py. Covered by 16 regression cases in python/djust/tests/test_c014_multi_tenant_asgi.py across 4 classes (trigger conditions, negative cases, suppression by short and full ID, and hint quality — the hint-quality class grew from 3 to 8 across two iterations of strategy-steering then deprecation-framing), including a gate-off self-test (#254 / #1468) confirming behavior-meaningful tests fail without the check.

[1.0.0rc7] - 2026-05-20

Fixed

  • VDOM {% if %}/{% elif %} branch swap no longer produces a doubled or stale subtree (#1550, #1552; #1552 was a P0 regression from 0.9.6rc2). Both bugs trace to a single root cause: dj-id counter collisions when last_vdom migrates across worker threads OR is msgpack-roundtripped through InMemoryStateBackend.get(). The thread-local djust_id counter generates monotonically-increasing ids during parse_html_continue, but the new thread's counter is independent of last_vdom's ids. The next parse generates ids 1..k that collide with surviving ids in last_vdom. The InsertSubtree.html patch then carries dj-ids matching other elements in the parent's child list, and the client's id-first :scope > [dj-id=N] resolver picks the newer element instead of the older one to remove — wrong subtree removed, old content survives. Why v0.9.6rc2 worked: pre-#1538 (commit 0a119962, serde-default fix), msgpack deserialize failed silently and state_backends/memory.py:118 returned None on every cache lookup, full-remounting on every event without running the diff path. Post-#1538 deserialize succeeds, the diff path runs, and the collision shape became reachable. (The earlier diff-layer hypothesis pursued in #1553 — child_d: None propagation post-#1538 — was empirically disconfirmed by VNode-level reproducers showing the differ correct at both 0.9.6rc2 AND 1.0.0rc4.) Fix: before each parse_html_continue in RustLiveViewBackend::render_with_diff and the text-fast-path entry, walk last_vdom, compute max_djust_id_in(old_vdom), and call ensure_id_counter_at_least(max + 1). The thread-local counter becomes effectively per-view, surviving thread handoff and msgpack roundtrip. Three new public helpers in djust_vdom: from_base62(s) (decode base62 string to u64), max_djust_id_in(node) (walk VNode tree for highest djust_id), ensure_id_counter_at_least(min) (monotonic counter advance). Covered by 12 Rust unit cases in crates/djust_vdom/tests/test_id_counter_monotonicity_1550_1552.rs (round-trip, invalid input rejection, max-walk on id-less / single / nested trees, monotonic semantics) plus 4 Python E2E cases in python/tests/test_if_elif_swap_e2e_1550_1552.py (collision-free InsertSubtree.html, uniquely-resolvable RemoveChild post-insert, if/else branch swap, msgpack-roundtrip counter advance).
  • Multi-line {# ... #} comments containing template-tag syntax no longer crash Django classical (#1551). djust ships two template renderers: the Rust engine (crates/djust_templates/src/lexer.rs:289-305) treats {# ... #} as opaque even across newlines, but Django's classical tokenizer (django.template.base.Lexer) uses a non-DOTALL regex — multi-line {# ... #} was NOT recognized as a single comment, so a {% if %} inside the comment body parsed as a real tag and raised TemplateSyntaxError. The mismatch was silent: templates rendered fine via the LiveView WebSocket path (Rust) and crashed via client.get() in pytest, Django's debug error renderer, or any view using render() directly. Follow-up to #1423. Fix: new djust.template.loaders.FilesystemLoader and djust.template.loaders.AppDirectoriesLoader — drop-in replacements for the standard Django loaders that preprocess {# ... #} blocks out of the template source before Django classical's tokenizer sees them. Single-line comments are stripped too (Django strips them anyway). Projects opt in by replacing the default loaders in TEMPLATES['OPTIONS']['loaders']. Performance: one regex pass per template load (<1ms); loaded templates are cached so this runs once per template, not per render. Covered by 10 regression cases in python/djust/tests/test_multiline_comment_parity_1551.py, including a control that pins the bug shape in vanilla Django classical.

[1.0.0rc6] - 2026-05-19

Security

  • Bumped idna 3.11 → 3.15 — patches CVE-2026-45409 (GHSA-65pc-fj4g-8rjx, Dependabot alert #101). Specially crafted inputs to idna.encode() ("٠" * N or "・" * N + "漢") hit the valid_contexto function prior to length rejection, so high values of N consumed significant resources — a ReDoS-style denial-of-service. Same class as CVE-2024-3651; the 2024 remediation was incomplete. idna 3.14 rejects long inputs early; 3.15 extends the early-reject to lesser-used per-label conversion and codec paths. idna is a transitive runtime dep (pulled in by anyio / httpx / httpcore / requests); the bump is a lockfile-only change via uv lock --upgrade-package idna, no direct-dep change in pyproject.toml. CVSS v4 6.9 / medium. Verified via full Python regression (7301 passed, 0 failed). Domain names cannot exceed 253 characters in normal usage, so the practical exposure surface was thin, but the fix removes the ReDoS class entirely.

Fixed

  • LiveView.request no longer triggers a "non-serializable ASGIRequest" warning on every mount/event (#1545). self.request was assigned by the HTTP post() path (mixins/request.py:489) and the WebSocket path (websocket.py:1940) AFTER __init__, so it sat OUTSIDE _framework_attrs and the state-snapshot machinery treated the ASGIRequest as user state — hitting the non-serializable fallback at serialization.py:557 and logging "LiveView state contains non-serializable value: ASGIRequest …" on every mount AND every event for every LiveView. The warning was cosmetic (the framework str()-stringifies the value and re-sets self.request to the live request on every request/event, so the stringified copy is never read back) but noisy enough to dilute the warning's signal for genuine app-author bugs. Fix: assign self.request: Any = None in LiveView.__init__ BEFORE the _framework_attrs = frozenset(self.__dict__.keys()) line at live_view.py:526request is now captured as framework state and excluded from the user-state snapshot. Matches the _framework_attrs snapshot-order invariant (#1393). The fix also adds "request" to the _FRAMEWORK_INTERNAL_ATTRS hard-coded frozenset used by _debug_state_sizes and the debug-toolbar observability path (discovered during regression-suite verification — 2 test_debug_state_sizes_* tests started reporting request as user state until both filters were updated). Covered by 5 regression cases in python/tests/test_liveview_request_framework_attr_1545.py, including a gate-off self-test (#254) confirming 4 of 5 tests fail without the fix.
  • crates/djust_live is now cargo test-able — extension-module gated behind a default-on Cargo feature (#1543). crates/djust_live carried PyO3's extension-module feature unconditionally, so cargo test -p djust_live failed at link time with ld: symbol(s) not found ... Py_True — the crate that holds djust._rust's entry point, the actor system, the RustLiveView backend, and (since #1541 / PR #1546) the PatchResponse round-trip regression tests had no fast Rust-native test feedback loop. make test worked around it with --exclude djust_live. Surfaced twice in the v1.0.0rc4 Phase-2 drain (PRs #1530, #1535) — standing structural constraint. The fix gates the feature behind a default-on Cargo feature ([features] default = ["extension-module"]; extension-module = ["pyo3/extension-module"]), so maturin develop / cargo build are unchanged but cargo test -p djust_live --no-default-features now links against libpython and runs. 37 djust_live tests now execute (including the 4 msgpack_round_trip_patch_response_* regression tests from PR #1546 / #1541 that previously compile-checked only). The Makefile test-rust target, the parallel test target, and the CI workflow (.github/workflows/test.yml) all gained a Phase 2 invocation that runs the djust_live tests with --no-default-features after the existing workspace-minus-djust_live pass. Maturin build path verified end-to-end (wheel build → import).
  • PatchResponse msgpack round-trip is now positionally-stable for every None/Some combination of patches and html (#1541). Sibling audit of #1538. PatchResponse is a plain #[derive(Serialize, Deserialize)] struct in crates/djust_live/src/actors/messages.rs, so under msgpack it serializes as a positional array — and its first two fields, patches: Option<Vec<Patch>> and html: Option<String>, carried #[serde(skip_serializing_if = "Option::is_none")] without #[serde(default)]. The fix that worked for #1538 (VNode.djust_id — add default) does not generalize: that fix only works for STRICTLY TRAILING optionals. For leading optionals like PatchResponse's, skip_serializing_if shifts later array elements into the wrong positional slot on deserialize — and default cannot repair this because the deserializer isn't running out of elements; it's reading wrong-typed values at the wrong positions (empirically witnessed in crates/djust_vdom/tests/wire_protocol_snapshot.rs :: msgpack_skip_with_default_works_for_trailing_optional_only). The correct fix for PatchResponse is to remove skip_serializing_if entirely — None is then serialized as msgpack nil (1 byte) and positional slots stay aligned. This is defense-in-depth: PatchResponse is not currently rmp_serde::to_vec'd on any production path (only the inner Vec<Patch> is at lib.rs:679), but future cross-process actor transport would have hit the same #1538 class. The #1448 wire-protocol snapshot suite now also carries 3 structural witness tests pinning the bug class so any future plain wire struct hitting the same pattern fails fast. Wire-format note: the JSON encoding of PatchResponse now always includes the patches and html keys (null rather than omitted); no current consumer parses PatchResponse JSON, but the existing inline serde_json test was updated to reflect the new always-present shape. Layer B regression tests for PatchResponse itself live inline in messages.rs and currently compile-check only (cargo test -p djust_live is blocked by #1543's unconditional extension-module feature); they will execute automatically once #1543 lands. 3 new msgpack_* cases in crates/djust_vdom/tests/wire_protocol_snapshot.rs and 4 new msgpack_round_trip_patch_response_* cases in crates/djust_live/src/actors/messages.rs.

Added

  • Audit: sync_to_async → native-async-ORM migration surface (#1434). A new audit, docs/audits/async-orm-2026-05.md, classifies every sync_to_async / async_to_sync call site in framework code — 126 sites across 14 files — and a companion benchmark, scripts/bench_sync_to_async_overhead.py, measures the per-crossing asgiref threadpool overhead empirically (~60 µs/crossing on the dev machine). The audit finds that issue #1434's premise does not hold: there are zero sync_to_async(Model.objects.X) call sites, only 3 ORM-category sites (all indirect auth/tenant helpers that fire once per connection at mount, never per event), and the ORM/cache-migratable fraction of per-event latency is 0% — below #1434's own 5% deprioritize gate. The audit recommends closing #1434. Internal/contributor documentation and tooling; no framework behavior change.

[1.0.0rc4] - 2026-05-19

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).

[1.0.0rc3] - 2026-05-18

Added

  • scripts/check-doc-snippets.py gained a check_security_style() AST walker — doc examples are now linted for djust auto-reject triggers (#1509, completes part (c) of #1500). Every fenced Python code block in README.md / QUICKSTART.md is now also scanned for the security/style anti-patterns the djust PR-checklist auto-rejects: a print() call, a print(f"...") call, an interpolating mark_safe(f"..."), a bare except: pass, and f-string logging (logger.<level>(f"...")). Each is a hard failure (exit 1) — a published doc snippet should never teach a pattern the framework's own review forbids. @csrf_exempt is reported as a non-blocking WARNING (it is sometimes legitimate with a documented justification). A new <!-- doc-snippet-check: anti-pattern --> HTML-comment marker placed immediately before a fenced block opts that block out of the security/style verdict — for deliberately-wrong "don't do this" examples — while still subjecting it to the existing syntax and import checks. This completes part (c) of #1500 (doc-example security/style linting), which the original #1500 PR deferred. Covered by tests/test_check_doc_snippets.py — 28 tests.
  • scripts/AUDIT_TEMPLATE.md — fill-in-the-blank template for new scripts/check-*.py audits (#1515). Codifies the canonical audit-script shape — the run() / build_arg_parser() / main() skeleton, the exit-code convention, the four wiring points (.pre-commit-config.yaml, .github/workflows/test.yml, a make target, and scripts/README.md), and the test conventions — so the next audit script is fill-in-the-blank rather than reverse-engineered from an existing one. scripts/README.md now references it. Internal contributor tooling.
  • ARIA for the P2/P3 component library — built-in roles, states, and accessible names for progress, badge, tooltip, and avatar (#1513). Extends the framework-wide component ARIA work (1.0.0rc1, unit 4) to the P2/P3 component tier so these components are correct to assistive technology out of the box. progress gets role="progressbar" plus aria-valuenow / aria-valuemin / aria-valuemax. badge gets a visually-hidden status-text element for screen readers, with its decorative dot marked aria-hidden="true". tooltip gets role="tooltip" on the tip element and aria-describedby wiring it to its trigger. avatar marks its initials-fallback path with role="img" + an aria-label, and marks the decorative status span aria-hidden="true". A decorative-icon aria-hidden="true" sweep was also applied across the P2/P3 component templates. card was deliberately left unchanged — it is a generic container, and assigning it a role would be over-reach. All changes are additive — no class was renamed and no existing element removed or reparented, so downstream CSS/JS selectors are unaffected (mirroring the add-only guarantee of PR #1491); the only new element is badge's visually-hidden status <span> (a fresh sr-only class, not a selector target). Separately, 3 unlabeled form controls in examples/demo_project templates — Y003 defects surfaced by PR #1512's dogfood pass — were given proper labels. This completes a slice of #1496's accessibility long-tail; the remainder — keyboard-interaction JS and djust_audit a11y reporting — is deferred to follow-up issues. Component-markup guarantees covered by python/djust/components/tests/test_component_aria.py — 27 new tests.

Fixed

  • _create_tarball exclude-matching anchored — substring containment dropped legitimately-named files from deploy tarballs (#1505). python/djust/deploy_cli.py's _create_tarball matched every TARBALL_EXCLUDES entry via substring containment (pattern in name), so any file or directory whose name merely contained an exclude token was over-excluded — venv dropped venvironment.py, dist dropped distance.py, media dropped media_helper.py, and similar lookalikes. TARBALL_EXCLUDES is now split into five typed groups — EXCLUDE_DIR_NAMES, EXCLUDE_DIR_SUFFIXES, EXCLUDE_FILE_SUFFIXES, EXCLUDE_FILENAMES, and EXCLUDE_FILENAME_STEMS — and the directory/file filters use anchored matching (exact basename / path-segment / suffix / stem) instead of substring containment, so only genuinely-matching artifacts are dropped. Sensitive files remain excluded with no credential-leak regression: a naive switch to exact-filename matching would have started shipping .env.production, .env.local, and SQLite sidecar files into deploy tarballs, so EXCLUDE_FILENAME_STEMS applies a file == stem or file.startswith(stem + ".") or file.startswith(stem + "-") rule — .env, .env.production, .env.local, db.sqlite3, and its WAL/SHM sidecars (db.sqlite3-wal, db.sqlite3-shm, etc.) are all still excluded, while a lookalike like .environment is correctly not excluded. Regression coverage in the TestCreateTarball class (python/tests/test_deploy_cli.py) — 60 tests in the file.
  • 4 HTML-attribute regexes in checks.py re-anchored to stop false-matching data-* attributes (#1514). _ACCESSIBLE_NAME_ATTR_RE, _HREF_ATTR_RE, _IMG_HAS_ALT_RE, and _CONTROL_ID_RE used a bare \b word-boundary anchor before the attribute name. Because - is a non-word character, \b matches inside a data- prefix (between data- and the attribute name), so each regex false-matched data-* attributes — e.g. _IMG_HAS_ALT_RE treated <img data-alt=...> as having a real alt (a Y002 false negative on a genuinely alt-less image), and _HREF_ATTR_RE could treat <a data-href=...> as a real link (a Y001 false positive). All four are now anchored with (?<![\w-]), which rejects both word characters and hyphens immediately before the attribute name. This is the same fix PR #1512 applied to the Y003/Y004 regexes — the third occurrence of this \b/data-* defect class. Recurrence is guarded against by a new meta-check test, TestChecksRegexHardening in python/djust/tests/test_accessibility_checks.py, which introspects every compiled attribute regex in checks.py and fails on any bare-\b anchor; the four _LIVE_RENDER_* template-tag-kwarg regexes are allowlisted since they scan {% %} kwargs rather than HTML attributes (#1517). 8 new tests in python/djust/tests/test_accessibility_checks.py.

[1.0.0rc2] - 2026-05-18

Added

  • scripts/check-adr-status.py — ADR status/version-line consistency audit (#1501). A new pre-commit/CI gate that enforces an invariant the #1493 cleanup established: an ADR with **Status**: Accepted must record where it shipped via a **Shipped in**: vX.Y.Z line, not a forward-looking **Target version**: line (a Target version on an Accepted ADR is stale metadata — the ADR is no longer targeting, it has shipped). The script hard-fails (exit 1) on any Accepted ADR still carrying a Target version line, and emits a soft warning for the inverse drift (a Proposed/Draft ADR that already names a Shipped in version). Wired into .pre-commit-config.yaml (runs when any docs/adr/ file is staged), .github/workflows/test.yml, and a make check-adr-status target. Covered by tests/test_check_adr_status.py — 11 tests.
  • scripts/check-doc-snippets.py — doc-snippet smoke test + mechanically-derivable claim assertions (#1500). A new pre-commit/CI gate that AST/import-checks every fenced Python code block in README.md and QUICKSTART.md — catching malformed snippets (syntax errors) and phantom imports (an import of a name djust does not export) before they reach a reader. It also asserts two mechanically-derivable doc claims against their source of truth: the Django minimum-version claim is checked against pyproject.toml, and the JS client-bundle-size claim is checked against the actual bundle (±3 KB tolerance). Wired into .pre-commit-config.yaml, .github/workflows/test.yml, and a make check-doc-snippets target. Covered by tests/test_check_doc_snippets.py — 14 tests. (Doc-example security/style linting is deferred to a follow-up issue.)
  • scripts/check-lockfile-versions.py — lockfile self-entry version audit (#1498, closes #1487). A new pre-commit/CI gate that asserts the djust self-entry recorded inside Cargo.lock and uv.lock matches the version declared in the corresponding manifest (Cargo.toml / pyproject.toml). A stale lockfile self-entry is a silent class of release bug — the manifest bumps but the lockfile keeps the old version, so a fresh resolve installs a mismatched package metadata version. Wired into the make version, make release, and make version-check targets, .github/workflows/test.yml, a .pre-commit-config.yaml hook (runs when a lockfile or manifest is staged), and documented in RELEASING.md. Covered by tests/test_check_lockfile_versions.py — 6 tests.
  • Two mandatory:false Stage-4 plan-template rules added to .pipeline-templates/feature-state.json + bugfix-state.json (#1502). Plan authors are now prompted to describe ARIA intent rather than pinning specific role values, and to grep constraint tables before labeling dependencies — both internal contributor-process guidance.
  • Two new Y accessibility system checks — Y003 / Y004 (#1496). Extends the Y category (a11Y) shipped in 1.0.0rc1 with two more regex template-scan checks. Y003 flags an <input> / <select> / <textarea> form control with no associated label (WCAG 1.3.1 / 3.3.2, Level A) — a control counts as labelled by a <label for>, a wrapping <label>, an aria-label, or an aria-labelledby; hidden/submit/button/reset/image input types are skipped, and controls with dynamically-injected ({% %} / {{ }}) attributes are treated conservatively as "label may be present" and not flagged (a data-type attribute is not mistaken for the input type). Y004 flags a positive tabindex value — a WCAG 2.4.3 focus-order anti-pattern; tabindex="0" / tabindex="-1" and interpolated values are valid and not flagged (a data-tabindex attribute is not mistaken for tabindex). Both emit a DjustWarning (never an error) and are suppressible via DJUST_CONFIG['suppress_checks'] or SILENCED_SYSTEM_CHECKS. Implemented in python/djust/checks.py; covered by the TestY003CheckIntegration and TestY004CheckIntegration classes in python/djust/tests/test_accessibility_checks.py — 26 tests.

Fixed

  • 12 ADRs' stale Target version metadata corrected to match reconciled Status (#1493). ADRs docs/adr/002008 and 013017 carried **Target version**: lines that no longer matched their reconciled **Status**: lines. Accepted ADRs that have shipped were relabelled **Shipped in**: vX.Y.Z; deferred ADRs were marked post-1.0 (deferred). The metadata now accurately reflects each ADR's lifecycle state — Accepted ADRs name where they landed, deferred ADRs are no longer mislabelled as targeting a near-term version. The new scripts/check-adr-status.py audit (see Added) prevents this drift class from recurring.
  • Orphaned TARBALL_EXCLUDES constant wired into _create_tarball — CodeQL #2330 py/unused-global-variable (#1495). python/djust/deploy_cli.py defined a TARBALL_EXCLUDES constant with a # Default patterns to exclude from tarball comment, but _create_tarball ignored it and hardcoded two separate inline pattern lists — leaving the constant with zero call sites. The constant is now the single source of truth, consulted for both the directory filter and the file filter. Its glob-prefixed entries (*.pyc, *.pyo, *.egg-info, *.log) were normalized to substring form (the function matches by in, not glob — a leading * would never match). This is an intended behavior change: deploy tarballs created by _create_tarball now also exclude .hg and .svn directories, logs/, media/, and staticfiles/ directories, and .log files (the old inline *.log entry never matched, due to the literal *, so .log files were silently shipped before). Nothing previously excluded becomes included. These are build/runtime artifacts (SCM metadata, collectstatic output, user uploads, runtime logs) that should be regenerated server-side rather than shipped in a source deploy tarball. Regression coverage in the new TestCreateTarball class (python/tests/test_deploy_cli.py) — 3 tests, all of which fail if the constant wiring is reverted.
  • Empty except: pass in check_psycopg3_for_pg_notify documented — CodeQL #2334 py/empty-except (#1495). A bare except Exception: pass in python/djust/checks.py (the psycopg2 __version__ read guard) was the only pass-only except in the file lacking an explanatory comment. It is now replaced with an explicit psycopg2_version = "" fallback assignment plus a comment explaining the guard: getattr already supplies a "" default, so the block only fires on a pathological __version__ descriptor, and psycopg2_version keeping its "" ("version unknown") value is the correct, intentional fallback. No behavior change.
  • README roadmap reconciliation — 2 stale checkboxes flipped + a broken register_component example corrected (#1497). The README roadmap had two unchecked items — Redis-backed session storage and horizontal scaling — that both shipped via RedisStateBackend; their checkboxes are now ticked. A register_component example snippet was also broken: it imported from the wrong package and used a Component base class that register_component rejects. The snippet is corrected to import and subclass LiveComponent (execution-verified). docs/roadmap.md carried no mechanical rot and is left unchanged.
  • Stale djust self-entry in uv.lock corrected 0.9.71.0.0rc1 (#1498, #1487). The uv.lock djust package self-entry still recorded 0.9.7 after the pyproject.toml bump to 1.0.0rc1, so a fresh resolve installed mismatched package metadata. The new scripts/check-lockfile-versions.py audit (see Added) prevents this drift class from recurring.

[1.0.0rc1] - 2026-05-17

v1.0.0 — the stability milestone. After the v0.9.x audit-driven bake, djust 1.0 makes its SemVer commitment: code written against the public API keeps working across every 1.x release. 1.0 is a consolidation release — there are no breaking changes from 0.9.7; an app that runs on 0.9.7 runs on 1.0 unchanged. The milestone shipped in six units: the Rust template-engine is / is not fix, the published API-stability + deprecation policy, a pre-1.0 dependency security sweep, framework-wide accessibility (the new Y system-check category plus built-in component ARIA), an ADR reconciliation pass, and this 1.0 documentation pass.

Added

  • API-stability + deprecation policy published — docs/API_STABILITY.md (v1.0.0 milestone, unit 2). The canonical, authoritative statement of djust's 1.0 SemVer commitment. Defines the public API surface SemVer covers (top-level djust exports, the djust.decorators decorators, public LiveView / LiveComponent / Component methods, the mixins re-exported from the top-level package, registered template tags/filters, documented config keys, and the snapshot-pinned WebSocket wire protocol) and what is explicitly not covered (underscore-prefixed names, djust.mixins.* internal-composition mixins, Rust crate internals, debug/dev-server/hot-reload internals). Documents the deprecation process — DeprecationWarning announcement, .. deprecated:: docstring marker, ### Deprecated CHANGELOG entry, mandatory migration path — and the support window: a symbol deprecated in 1.Y is removed no earlier than 2.0.0, with a >= 1.1.0 removal floor for the three pre-1.0 legacy symbols (@event, LiveViewForm, the _legacy theming module). A user-facing companion guide ships at docs/website/guides/api-stability.md, linked from the docs-site nav. This is unit 2 of the 6-unit v1.0.0 (Release Readiness) milestone — the policy is foundational and gates what the 1.0 docs pass documents.
  • Internal warn_deprecated deprecation helper — djust._deprecation (v1.0.0 milestone, unit 2). A single, standardized way djust emits a runtime DeprecationWarning. warn_deprecated(what, *, since, removed_in, instead=None, stacklevel=2) builds a consistent message naming the deprecated thing, the version it was deprecated in, a concrete earliest-removal version, and the migration path — mechanically enforcing the deprecation policy's "name a concrete removal version" and "name a replacement" rules. The module is underscore-prefixed and therefore framework-internal — it is itself covered by the policy's "underscore-prefixed names are internal" clause and is not a new public API symbol. The three existing DeprecationWarning call sites (@event, LiveViewForm, the _legacy theming module) now route through it.
  • Y accessibility system-check category — Y001 / Y002 (v1.0.0 milestone, unit 4). A new system-check category (mnemonic: a11Y) that regex-scans project template files for the two highest-value, lowest-false-positive accessibility defects. Y001 flags an interactive <button> / <a href> whose visible content is icon-only (an HTML entity, <svg>, or an <i>/<span> icon wrapper) and which has no aria-label / aria-labelledby / title — a screen-reader user hears nothing for such a control. Y002 flags an <img> tag with no alt attribute (WCAG 1.1.1, Level A); alt="" (decorative image) is correct and not flagged. Both emit a DjustWarning (never an error, so a stray false positive cannot fail manage.py check) with the file path and line number, and both are suppressible via DJUST_CONFIG['suppress_checks'] (or Django's SILENCED_SYSTEM_CHECKS). Templates that show literal HTML inside {% verbatim %} blocks are skipped. The category is the foundation — the scan plumbing exists, so adding Y003+ (heading order, form-label association, lang attribute) later is a single-function-body change. Implemented as a check_accessibility function in python/djust/checks.py; covered by python/djust/tests/test_accessibility_checks.py.
  • Framework-wide component ARIA support — built-in roles, states, and accessible names for the interactive component library (v1.0.0 milestone, unit 4). Eight interactive and feedback components now emit the ARIA markup a keyboard or screen-reader user needs, making them correct to assistive technology out of the box. modal gets role="dialog" + aria-modal="true", aria-labelledby to the title, and aria-label="Close" on the close button. tabs gets role="tablist"/role="tab"/role="tabpanel" with aria-selected and aria-controls/aria-labelledby pairing. accordion gets aria-expanded/aria-controls on triggers and role="region"/aria-labelledby on panels. dropdown gets aria-haspopup="menu", aria-expanded, aria-controls, and role="menu". alert gets role="alert" (error/warning) or role="status" (info/success) and aria-label="Dismiss". pagination gets aria-label="Pagination" on the nav, aria-current="page" on the active page, and aria-labels on page/arrow buttons. data_table gets keyboard-focusable (tabindex="0") sortable column headers. toast gets role/aria-live (assertive for errors, polite otherwise) and aria-label="Dismiss". Decorative glyphs and icons are marked aria-hidden="true". All changes are add-only ARIA attributes — no class was renamed and no element added, removed, or reparented, so downstream CSS/JS selectors are unaffected (the one structural addition is a benign <span class="data-table-sort-glyph" aria-hidden="true"> wrapper around the data_table sort glyph; downstream CSS targets the <th>, which is unchanged). ARIA pairing ids are derived deterministically from existing kwargs, keeping VDOM dj-id stable. New guide at docs/website/guides/accessibility.md; component-markup guarantees covered by python/djust/components/tests/test_component_aria.py.

Fixed

  • Stale and vague deprecation-warning messages corrected (#1483-adjacent, v1.0.0 milestone, unit 2). The LiveViewForm deprecation warning said the class would be "removed in djust 0.4" — a version long past (djust is at 0.9.7) — making the message actively misleading. It now names the policy-compliant >= 1.1.0 removal floor. The @event decorator and the _legacy theming module previously named no concrete removal version ("a future release" / no version at all); both now name the >= 1.1.0 floor. Additionally, all three deprecation warnings now point at the caller's frame rather than djust's own internal frame — the stacklevel values were corrected for the new warn_deprecated wrapper depth (including the metaclass-chain frames Django's DeclarativeFieldsMetaclass adds for the LiveViewForm.__init_subclass__ path), so python -W and pytest report the warning at the application code that triggered it.
  • Rust template renderer now supports Django's is / is not identity operators in {% if %} conditions (#1483). {% if x is None %} and {% if x is not None %} previously fell through every operator branch in evaluate_condition to the default Ok(false), so they silently evaluated false for all values — templates using this Django-standard syntax always took the {% else %} branch even when the condition was true. The renderer now implements is / is not with Python identity semantics: identity holds only for the singletons None, True, and False; arbitrary equal values (5 is 5, "a" is "a") are NOT treated as identical (CPython interning is an implementation detail templates must not rely on). Templates that previously fell through to the {% else %} branch — e.g. {% if some_value is not None %} for a non-None value — will now correctly take the {% if %} branch. This brings the Rust template engine to parity with the Django (Python) engine, which has supported is / is not natively since Django 4.0. Regression coverage in TestIsIdentityOperators (python/tests/test_template_conditions.py) plus Rust unit tests in crates/djust_templates/src/renderer.rs.

Security

  • Pre-1.0 dependency security sweep (v1.0.0 milestone, unit 3). Refreshed uv.lock to patched versions resolving all 8 open Dependabot advisories: Django 5.2.14 (1 medium + 2 low), urllib3 2.7.0 (2 high), ujson 5.12.1 (1 high), python-multipart 0.0.29 (1 high), and Twisted 26.4.0 (1 high). All 5 high-severity advisories are closed. The change is lockfile-only — no runtime API, pyproject.toml, or source change — and the full test suite (including the WebSocket/Channels paths exercised by the Twisted 25→26 major bump) passes.

[0.9.7] - 2026-05-16

Stable release. No code changes since v0.9.7rc3 (2026-05-12) — RC3 soaked for 4 days with djustlive on the pinned wheel and zero regressions reported. See the rc1, rc2, rc3 entries below for the full v0.9.7 changeset:

  • rc1: bundle init-order lint depth-N (#1449/#1406); wire-protocol snapshot pinning (#1456 starter + Batch 1/2/3 = #1457/#1461/#1462/#1463); LiveView state survives WS reconnect via enable_state_snapshot (#1465/#1466); investigation-class close of #1458 (pre-commit ruff auto-restage with 3 options surfaced); empirical Stage 11 canary canon (#1459/#1460).
  • rc2: same as rc1 (cargo lock + scaffolding bumps only).
  • rc3: 🚨 P0 — WS-event save block gated on enable_state_snapshot (#1475) + 150ms asyncio.wait_for defense-in-depth; pre-commit auto-restage wrapper landed (#1464).

[0.9.7rc3] - 2026-05-12

Fixed

  • 🚨 P0 — WS-event save block now gated on enable_state_snapshot (#1475). PR #1466 (0.9.7rc1) shipped an unconditional save block in handle_event (python/djust/websocket.py:3127-3224) — every successful WS event for every LiveView triggered async Django-session writes (2-4 round-trips per event: aset(__private), aset(view_key), _save_components_to_session via sync_to_async, asave()). The CHANGELOG entry for 0.9.7rc2 called this out as a deliberate design choice for HTTP-path symmetry. That symmetry argument broke on snapshot-on-idle infrastructure: HTTP requests are bounded by the response cycle, but WS events leave async session-backend I/O in flight beyond send_json. When djustlive's fc-proxy snapshotted a firecracker microVM 200ms after closing all backend WS conns at the host TCP layer, uvicorn ended up with pending async tasks frozen in the snapshot — TCP listener accepted post-restore but never returned response bytes. Forever. Site down indefinitely on idle cycles. 0.9.6 worked fine with the same 200ms settle; only the new save block extended the close-time tail latency beyond it. Fix: AND the existing top-level-identity gate with getattr(self.view_instance, "enable_state_snapshot", False). Default views ship 0.9.6 close-path semantics (zero async writes per event); only opt-in views pay the latency for the feature they asked for. Defense-in-depth: wraps the save body in asyncio.wait_for(..., timeout=0.150) so even opt-in views can't extend close-time tail latency under DB/Redis backpressure. On timeout, logs warning and continues — saves never break event handling. 3 new regression tests in python/djust/tests/test_ws_reconnect_state_1465.py: opt-out integration test via real WebsocketCommunicator (load-bearing negative assertion that session key stays absent), source-pin for the AND'd gate, and asyncio.wait_for semantics validation. Action #254 gate-off self-test: reverting the gate causes the 3 new + 2 source-pin tests to fail at their load-bearing assertions. Unblocks djustlive on 0.9.7+. djustlive operators: revert the 0.9.6 rootfs pin to 0.9.7rc3 once this release is published.

Added

  • Pre-commit auto-restage commit wrapper — opt-in (#1464). New scripts/git-commit-with-precommit.sh and make commit MSG="..." target. Runs uvx pre-commit run --files <staged> first; if hooks (ruff-format, ruff --fix, etc.) rewrote staged files the wrapper computes a per-file hash diff and git adds only the files whose content actually changed, then proceeds to git commit. Eliminates the ruff-bounce friction class where vanilla git commit exits non-zero with the reformat left unstaged — the failure mode hit 5× across v0.9.7-2 PRs (#1454, #1457, #1462, #1463, #1466) at ~30s per bounce. Bare git commit path unchanged; wrapper is opt-in. Path handling is NUL-delimited (git diff --cached -z) so filenames containing spaces or glob metacharacters round-trip safely. The per-file (not bulk -A) restage preserves unstaged hunks from a git add -p partial stage. Post-commit Action #122 verification (git rev-parse HEAD advanced) is built in. Pre-flight git rev-parse --git-dir check gives a clean exit-1 outside a repo. macOS bash 3.2 compatible (no declare -A, no mapfile -d). 9 regression tests in tests/test_git_commit_with_precommit.py.

[0.9.7rc2] - 2026-05-12

Added

  • LiveView state survives WS reconnect when enable_state_snapshot = True (#1465, supersedes stale PR #1429). Three companion changes to python/djust/websocket.py close a long-standing gap where enable_state_snapshot was effectively HTTP-only. (1) handle_event now mirrors the HTTP-path session save (mixins/request.py:603-609) after every successful WS event handler — fresh get_context_data() snapshot is filtered for LiveComponent instances, normalized, and aset to the Django session under liveview_<page_url>, with private (_-prefixed) attrs and components persisted alongside. (2) The load gate in handle_mount widens from if has_prerendered: to if has_prerendered or saved_state: so pure-WS reconnect (no SSR — e.g. after djustlive proxy force-closed the backend WS for snapshot) reaches the aget(view_key) lookup. (3) Mount response now skips the html field on resume — when mounted (state restored) AND has_prerendered, the client's DOM already reflects the saved state, so omitting html prevents a redundant morphdom-style DOM swap; client.js's e.html && (n.innerHTML=e.html) short-circuits cleanly. Mount response shrinks ~12KB → ~500 bytes on the resume path. The LOAD path (gate widening + skip-html on resume) is opt-in-gated via enable_state_snapshot / has_prerendered — behavior for non-opt-in views' load path is unchanged. The SAVE block in handle_event, however, fires unconditionally after every successful WS event, mirroring the HTTP-path mixins/request.py:603-609 semantics — the same session-write cost a POST incurs. Downstream consumers with high-event-rate views (e.g. dj-input per keystroke) on DB- or Redis-backed sessions should plan for that per-event write cost; the save is gated on top-level view identity (skips embedded child LiveComponent views to avoid wrong-key writes — child-view save coverage tracked at #1467) and wrapped in try/except so failures are caught and logged, never propagate to the event handler. Unblocks djustlive's "scale-to-zero with sub-50ms wake" story for stateful apps.

[0.9.7rc1] - 2026-05-12

Changed

  • Bundle init-order lint now does a deferral-pattern-aware depth-N call-graph walk (#1449, #1406). scripts/check-bundle-init-order.mjs previously caught only direct top-level reads of late-declared let/const. The walker now descends through synchronously-called function bodies (default depth 8) to catch the transitive TDZ class that PR #1370 hit — djustInit() → mountHooks() → _ensureHooksInit() → _activeHooks (read at top level via a transitive call chain, declared later in lexicographic concat order). Models deferral sites (addEventListener/removeEventListener, setTimeout/setInterval/setImmediate, requestAnimationFrame/queueMicrotask/requestIdleCallback, Promise .then/.catch/.finally, new XxxObserver(...)) so callbacks passed to those APIs are correctly treated as non-top-level. The walker uses an "effective-line" model: identifiers reached transitively through a top-level call at bundle line L are flagged only if decl.bundleLine > L, which eliminates the 16 false positives the naive depth-N version produced (per #1449). New CLI flags: --max-depth=N (default 8), --shallow-only (preserves v0.9.5 behavior). New env-var override BUNDLE_SRC_DIR for synthetic-bundle tests. The runtime regression test tests/js/bundle-init-no-tdz.test.js remains the simulate-bundle-init safety net — the two checks are complementary. Current main bundle is clean at default depth.

Tests

  • Wire-protocol snapshots: 12 final frames pinned (#1456 Batch 3 — closes #1456). noop (with optional appends), rate_limit_exceeded, pong, error.message variant (wire-distinct from error.error), navigate, upload_registered, upload_progress, reload, hvr-applied (kebab-case type — the only one in the protocol), presence_event (presence.py), streaming.patch, streaming.html_update, streaming.stream. 14 new tests, 39 total. Across the 4 PRs (#1457 starter + #1461 Batch 1 + #1462 Batch 2 + this), the entire send_json wire surface is now pinned.

  • Wire-protocol snapshots: 5 optional-feature frames pinned (#1456 Batch 2, follow-up to Batch 1 PR #1461). i18n, accessibility, focus, html_update (minimal + with reset_form/event_name conditional appends), connect. 6 new tests, 25 total in python/djust/tests/test_wire_protocol_snapshots.py. Closes Batch 2 of 3 in #1456; remaining ~12 shapes tracked there for Batch 3 (uploads, reload variants, control plane, presence, streaming).

  • Wire-protocol snapshots: 5 lifecycle frames pinned (#1456 Batch 1, follow-up to PR #1457). Extends python/djust/tests/test_wire_protocol_snapshots.py with mount_batch (envelope + optional navigate append), child_update, sticky_update, sticky_hold (with views + empty-list drop-all signal), and embedded_update. 7 new tests, 19 total. Closes Batch 1 of 3 in #1456; remaining ~17 shapes tracked there for Batches 2-3.

  • Wire-protocol JSON snapshot pinning for 8 highest-value Python-emitted frame shapes (#1448 starter, follow-up to PR #1444). Generalizes PR #1444's Rust-side Patch/VNode snapshot pinning to the Python send_json emit sites. python/djust/tests/test_wire_protocol_snapshots.py pins push_event, flash, page_metadata, patch (envelope), mount (with + without public_state), layout, navigation (inner-type-to-action promotion), and error against literal JSON strings. A field rename or default-value change at any of the 8 emit sites silently breaks deployed clients running older bundles — these snapshot tests catch it at test time. Key-order pinning is deliberate: mount.public_state and patch.html are appended after the initial dict literal, and Python 3.7+ preserves dict insertion order, so the JSON order is deterministic. The actual wire surface is ~30+ shapes; follow-up #1456 tracks the remaining ~22 in 2-3 grouped batches.

  • VDOM cluster carryovers — 24 new tests across 5 files (#1413, #1416, #1417, #1418, #1420). Five P3 hardeners extending crates/djust_vdom/tests/common/mod.rs (the harness from #1421). Test-only; no production code changes; none surfaced regressions on main. Test count: djust_vdom 248 → 272.

    • #1413proptest_round_trip_with_sync.rs: randomized counterpart to #1412's hand-crafted scenarios. 1 proptest fn × 64 cases × 5-20 steps of dj-if boundary toggles + inner mutations, asserts assert_handles_resolve invariant on every cycle.
    • #1416torture_html_round_trip.rs: 8 scenarios exercising the live VDOM → to_html → parse_html round-trip (plain/nested elements, text, dj-if single + nested, dj-key keyed children, dj-update="ignore" subtrees, mixed attrs incl. data-/aria-/role/href entities, text-with-entities).
    • #1417test_dj_update_ignore_dj_if_sync_ids_1417.rs: 3 scenarios for the dj-update="ignore" × dj-if × sync_ids three-way interaction. Verifies sync_ids preserves the ignored subtree's dj-ids across boundary swap-out → swap-in cycles.
    • #1418torture_deep_cascade_dj_if_1418.rs: 4 scenarios with 10/12/15 levels of nested dj-if boundaries at start/middle/end positions of the children list. Toggles deepest boundary across cycles.
    • #1420torture_patch_batch_ordering_1420.rs: 7 invariant scenarios + 1 snapshot. The canonical "SetAttr on kept child + RemoveChild on removed sibling" snapshot asserts RemoveChild comes at-or-after SetAttr in the batch (so any future emitter regression that reordered Remove ahead of Set would trip the test).

[0.9.6] - 2026-05-12

Stable promotion of 0.9.6rc3. No code changes since rc3. The rc1 → rc3 progression is summarized below.

Highlights vs 0.9.5

  • fix(theming){{ theme_head }} context-string parity with {% theme_head %} template tag (#1452 / #1453, regression introduced in rc2 and fixed in rc3). Production saw unstyled theme panels because the hand-built rc2 string dropped six output elements; rc3 routes the context string through the existing simple_tag so future tag additions flow through automatically.
  • fix(state-backends)RedisStateBackend ZstdCompressor/ZstdDecompressor moved to threading.local (#1430 / #1431). The previous shared-instance shape produced ZstdError, "Data corruption detected", and outright SIGSEGV inside ZSTD_decompressSequencesLong_default under concurrent load.
  • fix(state-backends)InMemoryStateBackend.get() discards corrupt entries instead of returning a shared in-memory ref (#1410 / #1438). Closes the cross-connection state-leak class introduced when RustLiveView.deserialize_msgpack raised after a hot-swap struct change.
  • feat(checks)djust.D001 system check for Postgres-configured-without-psycopg[binary]>=3.2 misconfig (#1433 / #1440).
  • perf(theming)theme_context now caches its output by ThemeState tuple AND pre-renders theme_panel / theme_mode_toggle / theme_preset_selector as context strings (#1435 / #1437 / #1442 / #1443). Per-request cost ~1-3 ms → ~5-10 µs post-warmup on theme-rendering pages.
  • perf(tenants)TenantMiddleware short-circuits when no resolver is configured (#1436 / #1441). Saves ~2-5% per-request CPU for djust[tenants] deploys without tenant opt-in.
  • feat(deploy)djust deploy CLI is now a guided end-to-end onboarding (login → resolve slug → confirm project → deploy) with OAuth Auth Code + PKCE browser flow login (#1422). RFC 8252 loopback redirect; no client_secret; refresh-token rotation.
  • test(vdom) — wire-protocol JSON snapshot tests for every Patch variant + VNode struct (#1419 / #1444). Pins the Rust↔JS contract against silent serde shape changes.
  • Plus the rc1–rc3 contents already shipped — see those entries below.

[0.9.6rc3] - 2026-05-10

Fixed

  • {{ theme_head }} context-string emits the same payload as {% theme_head %} template tag (#1452, regression in 0.9.6rc2). The 0.9.6rc2 implementation of _render_theme_outputs hand-built a small string for theme_head that dropped the <link> to djust_theming/css/components.css (where .theme-panel* rules live), the print.css link, the components.js script, the deferred-CSS preload, the RTL direction attribute, and the cookie-namespace JS prefix. Production saw unstyled theme panels because components.css never loaded. Fix: route {{ theme_head }} through the existing theme_head simple_tag (same shape #1443 already adopted for theme_panel / theme_mode_toggle / theme_preset_selector), so any future addition to the classic tag's output flows through to the context-string form automatically. Also: per-tag fail-soft — one tag's failure (broken manifest, downstream shadowing) no longer blanks the other pre-renders.

[0.9.6rc2] - 2026-05-09

Performance

  • theme_context now pre-renders theme_panel, theme_mode_toggle, and theme_preset_selector as context strings (#1435). Templates can now use {{ theme_panel }} / {{ theme_mode_toggle }} / {{ theme_preset_selector }} instead of the corresponding {% … %} tags. The work runs once per request in the context processor instead of once per {% … %} invocation — meaningful when the same tag appears multiple times on a page (e.g., djust-scaffold's base.html had {% theme_panel %} twice). Customization-with-args still uses the {% … %} form. If a tag function raises (broken manifest, missing template, downstream shadowing), pre-renders come back as empty strings instead of 500-ing the request.
  • theme_context now caches its rendered output by ThemeState tuple (#1437). The Django context processor djust.theming.context_processors.theme_context previously ran the full CSS-generation + theme-switcher HTML pipeline on every templating request. Output is now lru_cache(maxsize=512)'d on (theme, preset, pack, mode, resolved_mode, layout, presets_key) — a pure function of state, no request data flows in. djust's catalog of ~60 presets × 2 modes × handful of packs fits comfortably under the cache size. Per-request cost: ~1-3 ms → ~5-10 µs post-warmup. New djust.theming.context_processors.clear_theme_context_cache() exposed for theme-pack hot-reload and tests.
  • TenantMiddleware short-circuits when no resolver is configured (#1436). When neither DJUST_CONFIG['TENANT_RESOLVER'] nor DJUST_TENANTS is set, the middleware now bypasses the resolver call, the thread-local set/clear pair, and the required-tenant gate — switching __call__ to a straight get_response(request) passthrough. Saves ~2-5% per-request CPU for consumers with djust[tenants] installed but no tenant opt-in (single-tenant deploys, scaffold starters, demo apps). Consumers who set either config keep the full path; request.tenant is still set to None on the no-op path so getattr(request, "tenant", None) callers see the same shape.

Added

  • System check djust.D001 — warn when Postgres is configured but psycopg[binary]>=3.2 is not installed (#1433). djust's db.notifications (LISTEN/NOTIFY bridge) requires psycopg3. The 0.9.5 cycle hardened the runtime path to permanent-fail with a WARNING when @notify_on_save actually fires (#1357), but operators who deploy the misconfig without an active consumer wouldn't see the warning until much later. D001 surfaces it at manage.py check / runserver startup, before traffic. Fires only when the default DB engine is Postgres AND psycopg2 is importable AND psycopg (3.x) is missing or at version < 3.2. Silenceable per-project via SILENCED_SYSTEM_CHECKS = ['djust.D001'].

Fixed

  • RedisStateBackend now uses per-thread ZstdCompressor/ZstdDecompressor (#1430). zstandard.ZstdCompressor and ZstdDecompressor are NOT thread-safe (python-zstandard #244, closed "by design"). The previous RedisStateBackend.__init__ stored a single instance of each on self, so concurrent callers raced on the C-level state. Symptoms ranged from ZstdError("Unknown frame descriptor") and "Data corruption detected" to outright SIGSEGV inside ZSTD_decompressSequencesLong_default (reproduced on Linux 4.14 + Python 3.12 + zstandard 0.25.0; the segfault took down a microVM in production). Both objects now live in a threading.local, accessed lazily via _get_compressor() / _get_decompressor(). Each thread gets its own instance — no shared state, no race. Per-thread instances are reused within a thread (no per-call construction overhead).
  • InMemoryStateBackend.get() discards corrupt entries instead of returning the shared in-memory ref (#1410). When RustLiveView.deserialize_msgpack raised — typically after a hot-swap struct change or msgpack schema drift — the previous fallback returned the cached object directly. Two concurrent connections to the same view then shared one _rust_view, and mutations from connection A leaked into connection B's render context. After a cargo build of djust_vdom + .so swap, fresh navigations could re-render with state from the prior session. Now the backend pops the corrupt entry from its in-memory dict and returns None; the caller's mount path treats the cache as cold and runs mount() cleanly. Discovered during the #1408 investigation.

[0.9.6rc1] - 2026-05-07

Added

  • djust deploy — guided end-to-end onboarding (#1422). The CLI now walks first-time users through the full chain in a single djust deploy invocation: log in → resolve project slug (CLI arg → pyproject.toml → prompt) → confirm the project exists server-side (or offer to create it) → deploy. Each step is skipped if its precondition is already met, so power users see only the deploy itself. Slug is auto-saved to pyproject.toml ([tool.djust.deploy] project = "…") so subsequent runs are zero-prompt; the writer is idempotent and survives a server-side slug-uniquification round-trip without producing a duplicate-table TOML. Flags: --yes/-y auto-accepts every confirmation (CI / scripts), --no-create fails fast if the project doesn't exist server-side and propagates interactive=False through the slug-resolution + login chain so CI runs with no creds and no slug exit instead of prompting.
  • djust deploy login is now an OAuth Auth Code + PKCE browser flow (#1422). Replaces the previous email/password prompt. The CLI binds an ephemeral 127.0.0.1 port (RFC 8252 loopback redirect), opens the browser to djustlive's /o/authorize/, and exchanges the returned code at /o/token/ for an access + refresh + id_token. PKCE (RFC 7636 / S256) defends code interception; the CLI is a public client (no client_secret). Credential format extends to {auth_scheme: "bearer", access_token, refresh_token, expires_at, email, server_url}; the legacy {token: …} DRF shape is still honored transparently until those tokens expire. On /me/ 401 the CLI silently tries refresh_token before re-launching the browser, so weeks-apart deploys don't bounce the user. Loopback callback HTML emits Referrer-Policy: no-referrer + Cache-Control: no-store to keep the auth code out of any future Referer header or browser/proxy cache (RFC 8252 §8.10). State parameter compared with secrets.compare_digest. --server / DJUST_SERVER enforces https:// except for 127.0.0.1 / localhost dev hosts.

[0.9.5] - 2026-05-07

Stable promotion of 0.9.5rc4. No code changes since rc4. The rc1 → rc4 progression is summarized below.

Highlights vs 0.9.4

  • fix(vdom)sync_ids is now dj-if-boundary-aware (#1408 / #1411). Closes the cross-render dj-id drift class that produced visible content-bleed on {% if %} branch swaps in production.
  • test(vdom) — multi-cycle sync_ids round-trip torture (#1412 / #1414) exercising the production server loop (diff → apply on a faithful client tracker → sync_ids → store as last_vdom). Catches the #1408 regression class locally on cargo test.
  • test(vdom) — shared test harness extracted to crates/djust_vdom/tests/common/mod.rs (#1415 / #1421). Unblocks 5 follow-up torture/fuzz issues (#1413, #1416, #1417, #1418, #1419, #1420).
  • Plus the rc1–rc3 contents already shipped — see those entries below.

[0.9.5rc4] - 2026-05-07

Tests

  • Multi-cycle sync_ids round-trip torture (#1412 — regression-class hardener for #1408). Added crates/djust_vdom/tests/torture_round_trip_with_sync.rs exercising the production server loop (diffapply_patches on a faithful client tracker → sync_ids → store as last_vdom) across 4 scenarios: three-branch tab toggle, five-boundary independent toggles (THE bug-trigger; trips at round 4 on commit a44e63cb pre-fix), long alternation under matched boundary id, and same-tag siblings around unmatched boundaries. The crate's existing torture (tests/torture_test.rs, 42 tests) and proptest fuzz (tests/fuzz_test.rs) only test single-diff correctness; this file exercises the cross-render invariant — every emitted patch's targeting handle (d / child_d / ref_d) must resolve in the client tracker — that #1408 violated. Test count: djust_vdom 228 → 232. Proptest-randomized variant filed as follow-up #1413.
  • Test harness extracted to crates/djust_vdom/tests/common/mod.rs (#1415). Pure refactor; deduplicated dj-if marker helpers (dj_if_open, dj_if_close, is_dj_if_open*, match_close_idx), VNode lookup helpers, dj-if subtree manipulation, the subtree-aware apply_all patch applier, the cross-render assert_handles_resolve invariant checker, and the sequential IdGen dj-id generator. Unblocks #1413, #1416, #1417, #1418, #1419, #1420 — each will be a small focused PR rather than another 200-LOC duplication of helpers.

Fixed

  • VDOM sync_ids is now dj-if-boundary-aware (#1408). diff::diff_children already aligned children across <!--dj-if id="…"--> boundary swaps via dj_if_pre_pass (#1358); the post-diff sync_ids did not, falling through to sync_ids_keyed/sync_ids_indexed and positionally pairing children regardless of branch identity. After a {% if %} branch swap, fresh djust_ids on new-branch content were overwritten by stale ids from the unmatched-old-branch — the next render's diff then emitted RemoveChild/SetAttr patches whose child_d/d referenced ids the client DOM never had, leaving orphan content from the prior branch. Added sync_ids_dj_if_pre_pass mirroring dj_if_pre_pass's shape (id-only-in-OLD → skip, id-only-in-NEW → skip with fresh ids preserved, id-in-BOTH → recurse, non-boundary siblings → relative-order pairing via build_excluded_mask). Verified at the DJUST_VDOM_TRACE=1 level: pre-fix a tab-swap diff emitted RemoveChild child_d="2y" (a stale id from a render two cycles back) for the bottom-tab content slot; post-fix it correctly emits child_d="5m" matching what the previous diff's InsertChild placed there. Reproduced and verified-fixed against a downstream consumer's bottom-tab-swap reproducer. Regression test in crates/djust_vdom/tests/test_sync_ids_dj_if_1408.rs (4 cases; 3 fail pre-fix). All 228 existing djust_vdom tests still pass.

[0.9.5rc3] - 2026-05-07

Added

  • Bundle-init-order structural lint: scripts/check-bundle-init-order.mjs (#1372, #1370 follow-up). Static check for the direct-top-level-read TDZ subclass — catches the case where a late-declared let/const is referenced directly at top level of an earlier module. Enumerates module-scope let/const across python/djust/static/djust/src/*.js, finds top-level use sites via acorn AST, and flags any cross-module use where the use-site lex-orders BEFORE the declaration. Does NOT catch transitive call-graph TDZ (e.g., djustInit() calling mountHooks() whose body reads a late let — this is exactly the #1370 shape). The runtime regression test bundle-init-no-tdz.test.js continues to catch transitive cases via JSDOM eval. The two checks are complementary; extending this lint to a depth-N call-graph walker is filed as a follow-up. Wired into Makefile (make check-bundle-init-order is part of make check) and pre-push hook. Currently clean on main.

Changed

  • JS micro-cleanup: deduplicated transition helpers + tightened routeMap access (#1360, #1361). Two follow-ups deferred from PR #1359 Stage 11.
    • _parseTimeMs and _computeTransitionTiming extracted from 41-dj-transition.js and 42-dj-remove.js into a new shared 40a-transition-helpers.js (loads before both consumers per the bundle's lexicographic concat order). CodeQL alerts at client.js:13162 and :13171 ("Conflicting function declarations") clear; bundle has exactly one definition of each (#1360).
    • routeMap[pathname] access in 18-navigation.js replaced with an Object.entries(routeMap) walk — prototype-pollution-immune by construction (own enumerable string-keyed entries only). Lints cleanly without eslint-disable-next-line. Same shape applied to 46-state-snapshot.js. Map conversion (option B) rejected because it would change the wire-protocol shape emitted by python/djust/routing.py and break downstream consumers (#1361).

Removed

  • Dead InMemoryStateBackend.get_and_update() removed (#1356). Method had zero callers and would re-introduce the #1353 shared-mutable-state race class if a future caller was added without auditing. PR #1355 fixed the sibling get() to clone via msgpack round-trip; get_and_update() was overlooked. Per the issue body's preferred-fix order, deletion was cleanest. Removes ~22 lines of dead code from python/djust/state_backends/memory.py. (Surfaced as PR #1355 Stage 13 Re-Review #1.)

Fixed

  • Node::Include round-trip no longer double-quotes the template path (#1396). Parser was preserving the outer quotes on Include.template; emitter nodes_to_template_string then wrapped again, producing {% include ""partials/header.html"" %} on round-trip. Surfaced during PR #1397's conversion of round-trip tests to drive from parser output (Action #158 working as designed). Fixed by aligning the parser to strip outer quotes (matching the existing Extends, Static, and Now contracts) — single source of truth, emitter unchanged. test_nodes_to_template_string_include un-ignored. Added test_nodes_to_template_string_now for defense-in-depth.

Security

  • sanitize_for_log cache_key on HTTP cache-lookup debug log (#1368). Pre-existing log-injection asymmetry between WebSocket and HTTP paths in python/djust/mixins/rust_bridge.py: the WS site at line 333 sanitized correctly; the HTTP site at line 363 did not. Since cache_key derives from request.path (user-controlled), an attacker-supplied path like /page/\n[FAKE LOG ENTRY] could inject newlines into the log stream. Mirrors the WS-path call to sanitize_for_log(self._cache_key) and adds the matching CodeQL annotation. Surfaced in PR #1367 Stage 11 SHOULD-FIX #3 (deferred per Action #1079).

[0.9.5rc2] - 2026-05-06

Added

  • New framework helper: djust.utils.emit_one_shot_class_warning(cls, key, message, *args) (#1392). Reusable pattern for "framework can't help mechanically; tell the developer loudly." Sets a class-level sentinel attr _djust_warned_<key> so subsequent instances of the same class don't repeat the warning. Subclasses get their own sentinel via cls.__dict__.get (avoids attribute inheritance). Refactored the existing snapshot-truncation warning in python/djust/websocket.py to use it. Pattern from PR #1326, canonicalized via Retro v0.9.3-2 finding #4.

Changed

  • Process canon batch: 8 retro-filed items into CLAUDE.md, pipeline templates, and docs/website/guides/authorization.md (#1345, #1377, #1385, #1386, #1389, #1391, #1393).

    • .pipeline-templates/bugfix-state.json Stage 4: mandatory checklist item to verify cited cause for retro-filed issues before locking the fix scope (#1345).
    • .pipeline-templates/feature-state.json + bugfix-state.json Stage 7: mandatory checklist item requiring disconfirming citations during self-review — bias toward active falsification rather than passive confirmation (#1386).
    • CLAUDE.md Bug-report triage section: rule that multi-reopen issues require a bit-exact runnable reproducer against the reporter's environment before "root cause confirmed" (#1389); _framework_attrs snapshot-order invariant note (#1393).
    • CLAUDE.md Process Canon: filter-migration grep canon (when changing a filter convention, grep all call sites for the OLD pattern) (#1391); split-foundation soak-time guidance for solo-author case (no external consumers → soak optional) (#1385).
    • python/djust/live_view.py: comment block on _framework_attrs = frozenset(self.__dict__.keys()) documenting the BEFORE-snapshot vs AFTER-snapshot semantics (#1393).
    • docs/website/guides/authorization.md: WS-communicator test pattern section showing how to test the per-event object-permission re-execution path (#1377).
  • scripts/check-test-coverage.py now verifies Makefile and pyproject.toml testpaths bidirectionally (#1346, defense-in-depth on #1339). The original one-directional check caught the case where the Makefile missed a path that pyproject.toml declared (#1339, the bug that left python/djust/tests/ uncollected for months). The reverse direction — a path added to the Makefile but missing from pyproject.toml, or removed from pyproject and still in Makefile — would have gone unflagged. Now fails loud with a clear set diff in either direction.

  • Refreshed stale (file as new issue) placeholders in May 2026 audit docs (#1342). docs/audits/lifecycle-2026-05.md and docs/audits/decorator-contract-2026-05.md now cite real issue numbers for the 9 follow-ups (#1283-#1291), all of which are closed. The lifecycle §3 #7 row (mount() pre/post snapshot) gets a closure annotation noting _capture_dirty_baseline already runs in production at python/djust/websocket.py:2145. /djust-dev audit-status will now report accurate state to app authors.

  • Round-trip identity tests for AST-shape contracts now drive input from parser output (#1388, Action Tracker #158). 12 tests in crates/djust_templates/src/inheritance.rs migrated from manually-constructed Node::* ASTs to parse(tokenize(source)). Previously, manual construction bypassed parser invariants like parse_filter_specs's outer-quote preservation, so contract violations silently passed (the original PR #1086 / #1081 case). Conversion uncovered a previously-masked bug in nodes_to_template_string for {% include %} (parser stores the path with surrounding quotes; emitter wraps again, producing {% include ""path.html"" %} on round-trip). Out of scope per #1079 broader-sweep canon — filed as #1396 with the affected test marked #[ignore].

  • X008 audit heuristic now walks same-module MRO and recognizes broader URL-kwarg-binding shapes (#1382, #1383, deferred from PR #1381 Stage 11). Two improvements to python/djust/audit_ast.py:

    • _class_has_attribute and _class_defines_method accept an optional class_index parameter and walk the same-module MRO via static analysis when supplied. The X008 IDOR-shape checker uses this so views inheriting permission_required from a base mixin (or inheriting has_object_permission / check_permissions overrides) are correctly classified. Cross-module bases are silently skipped — by design, the static analysis is module-local. Cycle guard via visited-set in _walk_mro_static prevents recursion on class A(B): ...; class B(A): ....
    • _mount_assigns_url_kwarg_id now recognizes three additional RHS shapes beyond bare self.x = x: self.kwargs["x"] (Subscript), int(x) / str(x) / uuid(x) / UUID(x) (whitelisted casts; literal arguments like int(42) correctly do NOT match), and (self.)kwargs.get("x"[, default]). Reduces false-negatives from views using mixins or coercion.

    10 new test cases in TestX008IDORShapeNeedsObjectPermission cover the new branches plus the X001-non-co-fire invariant.

Fixed

  • Sticky-child views with overridden get_object() no longer silently skip per-event object-permission checks (#1380, deferred from PR #1378 Stage 11 🟡 #2). When a sticky/embedded child view's owner_request is None (the parent failed to stamp request because mixins/sticky.py:212-218's read-only-child constraint raised AttributeError), _validate_event_security now FAILS CLOSED if the child opted into the object-permission lifecycle: sends a permission_denied error frame and logs a WARNING instead of returning the handler. Views that did NOT override get_object are unchanged (no security check is active for them, so silent fall-through is correct). Companion change: mixins/sticky.py:215 logger.debuglogger.warning on the read-only-child path so the upstream gap is observable in production logs at its source.

[0.9.5rc1] - 2026-05-06

Added

  • get_object() + has_object_permission() lifecycle hooks on LiveView — Foundation 1 of object-level authorization (#1373, ADR-017). Iter 1 of 3 toward closing a structural IDOR class that affects any djust app where the LiveView is bound to a single object via URL kwarg (document_id, user_id, <resource>_id, etc.). The natural placement for object-level checks (get_context_data) runs too late: by the time it fails, mount() has set up the WS-session-scoped state and event handlers can fire against the foreign object — the exact bug class this lifecycle closes.

    This iteration ships mount-time enforcement only. Per-event re-execution lands in v0.9.5-1b after the API surface soaks one release; tooling (djust check IDOR-shape heuristic, authorization.md guide, djust-dev skill principle) lands in v0.9.5-1c. The split-foundation rollout follows the canon from Action #1122.

    Two new public methods on LiveView, both default to no-op (the lifecycle is opt-in via override):

    • get_object(self) -> Optional[Any] — return the view's primary object (typically the FK lookup Model.objects.get(pk=self.<x>_id)). Default returns None so views that don't override see zero behavior change.
    • has_object_permission(self, request, obj) -> bool — return True if the request user may access obj. Default returns True. Called at mount-time when get_object is overridden.
    • _invalidate_object_cache(self) -> None — handlers call this when they mutate state affecting access (e.g. reassigning the FK that determines ownership). Without invalidation, a cached self._object would let a formerly-authorized user retain access until WS reconnect.

    The framework caches the result of get_object() as self._object after mount — reuse it from event handlers and get_context_data rather than re-querying. Cache is automatically reset on snapshot/state restore (it's a framework slot, not user-private state), which handles the "object reassigned while user was disconnected" case automatically.

    OWASP IDOR mitigation built in: when get_object() returns None, has_object_permission is not called (the caller raises 404 if it wants to). The framework also catches Django's ObjectDoesNotExist (parent of every Model.DoesNotExist) AND django.http.Http404 (raised by get_object_or_404) inside check_object_permission and treats them as None — automating the 404-shape pattern so a naive Model.objects.get(pk=missing) or get_object_or_404(...) doesn't leak existence via DEBUG=True traceback. Note the two are listed as separate catches because Http404 inherits from Exception directly, not from ObjectDoesNotExist.

    Order of auth checks (logical onion): login_requiredpermission_requiredcheck_permissions (existing) → has_object_permission (NEW). The new step has its own physical call site at websocket.py:handle_mount post-mount (not inside check_view_auth), because get_object() reads self.<x>_id populated by the user's mount() body — check_view_auth runs pre-mount when self.kwargs isn't yet bound. ADR-017 § Decision 5 documents the rationale.

    New helpers in djust.auth.core:

    • check_object_permission(view_instance, request) — re-exported from djust.auth. Wires get_object + has_object_permission together; raises PermissionDenied on denial.
    • _has_custom_get_object(view_instance) — MRO walk that gates the lifecycle as opt-in; mirrors _has_custom_check_permissions.

    Wire-protocol semantics: mount-time denial closes the WS with code 4403 + {"type": "error", "message": "Permission denied"} error frame, mirroring the existing pre-mount denial path at websocket.py:1953-1955.

    Backwards compatible: views that don't override get_object see zero behavior change (verified empirically — full pytest suite of 4670 tests + 1563 JS tests passes unchanged). Apps that already use check_permissions keep working; the new step runs after.

    9 regression tests in tests/integration/test_object_permission_mount.py: denial via return False raises PermissionDenied; allow populates self._object; no-override is a no-op; _invalidate_object_cache resets the cache (verified via call counter); get_object()=None skips has_object_permission; get_object() raising ObjectDoesNotExist is treated as None; get_object() raising Http404 is treated as None (defense-in-depth against the DEBUG=True traceback leak); has_object_permission raising PermissionDenied directly (vs return False) preserves the developer's custom message; get_object() returning a falsy non-None value (False, 0, "") IS treated as a valid object — has_object_permission is called (locks the strict-identity is None contract).

  • Per-event object-permission re-execution — Foundation 2 of object-level authorization (#1373, ADR-017 § Decision 7). Iter 2 of 3, stacking on the v0.9.5-1a foundation. Closes the IDOR class END-TO-END at the per-event surface, not just at mount.

    Without this iteration, an attacker with a valid session for an object they no longer have access to (e.g., access was revoked mid-session, or they crafted a session via timing) could still fire event-handler frames against that object — the foundation only checked permission at mount time. With this iteration, every event handler dispatch re-runs has_object_permission(request, obj) before the handler body executes, automatically.

    Wired into djust.websocket_utils._validate_event_security — the centralized helper called by all event-dispatch paths in djust (actor, component, view dispatch in websocket.py, plus HTTP-runtime in runtime.py and SSE in sse.py — five call sites total). Adding the check there covers all transports without per-site changes.

    Per-event denial semantics:

    • has_object_permission(...) returns FalsePermissionDenied raised by check_object_permission → caught by _validate_event_securitysend_error("Access denied for this object.", code="permission_denied")return None (caller skips handler dispatch). WS stays open — the user is still authenticated; only this specific action against this specific object is forbidden. (Compare to mount-time denial, which closes the WS with code 4403.)
    • get_object() returning None or raising ObjectDoesNotExist/Http404 → no denial, handler proceeds (consistent with mount-time semantics; the developer's get_object() already implements the OWASP 404-shape).
    • View doesn't override get_object_has_custom_get_object short-circuit fires; zero overhead, zero behavior change.

    Wire-protocol error frame for per-event denial: {"type": "error", "error": "Access denied for this object.", "code": "permission_denied"}. The structured code field lets clients distinguish permission-denied from other error types and revert optimistic UI updates accordingly.

    Cache-population order fix (Stage 11 nit from -1a, addressed here): check_object_permission now sets self._object = obj only AFTER has_object_permission returns True — never on denial, never on DNE/Http404 (those reset to None). Prevents cache poisoning across denials, which becomes load-bearing for per-event re-checks (a stale "allowed" cache could let a denied user retain access).

    State-restore interaction: self._object is allocated in LiveView.__init__ BEFORE the _framework_attrs snapshot, so it's classified as a framework slot and excluded from msgpack-serialized user-private state. After WS reconnect / state-restore, the cache is None and get_object() re-runs fresh — handles "object reassigned while user was disconnected" automatically. New regression test test_object_cache_is_framework_slot_excluded_from_user_state locks this contract.

    Embedded child views ({% live_render %}): when an event targets a child view via view_id, the dispatch sites pass the resolved target_view (the child) to _validate_event_security. The check uses the CHILD's get_object/has_object_permission, NOT the parent's. New regression test test_embedded_child_view_uses_child_get_object verifies.

    Fail-closed on developer-code exceptions (Stage 11 🟡 finding): if get_object() or has_object_permission() raise anything other than PermissionDenied (e.g., an AttributeError in the developer's body), the new check catches it, logs an exception-level traceback, and treats it as denial. Security code must not fail-OPEN when the auth predicate crashes. Default-deny is the safe response.

    Backwards compatible: views without get_object override see zero behavior change. Existing handler-level @permission_required decorators continue to work; the new check runs after them.

    9 new regression tests in tests/integration/test_object_permission_event.py: cache-not-poisoned-on-denial; cache-populated-only-on-success; per-event denial sends error frame and keeps WS open (handler body verified to NOT execute via sentinel); per-event allow returns the handler; per-event no-override is a no-op; DNE handling; framework-slot exclusion from user state; fail-closed on non-PermissionDenied developer exceptions; embedded-child resolution.

  • Tooling layer for object-level authorization — Foundation 3 of object-level authorization (#1373, ADR-017 § Decision 8). Final iteration of the split-foundation rollout. Closes the documentation, lint, and skill gap so app authors can DISCOVER the lifecycle and migrate to it.

    Three artifacts:

    • New djust check heuristic — X008 (python/djust/audit_ast.py). Flags any view matching the IDOR shape: extends LiveView (or matches the existing detail-view heuristic), has permission_required set, mount() assigns from a URL kwarg ending in _id (the canonical self.document_id = document_id pattern), at least one @event_handler-decorated method reads self.<that>_id, AND does NOT override has_object_permission or check_permissions. Severity: warning. Details point to docs/website/guides/authorization.md for the migration recipe. Distinct from existing X001 (.get(pk=user_input) pattern); X008 is structural — it flags the shape regardless of fetch mechanism. Run python manage.py djust_audit --ast to find matches.

    • New guide docs/website/guides/authorization.md. Walks through the four-layer auth onion (login → role → custom → object), the canonical get_object() + has_object_permission() pattern, OWASP 404-shape mitigation, cache invariants and _invalidate_object_cache() discipline, wire-protocol error frames (mount close 4403 vs per-event code: permission_denied), defense-in-depth via manager-level for_user() filtering, and a worked migration example (before/after diff for hand-rolled get_context_data IDOR checks).

    • djust-dev skill principle catalog updated. Two new entries: "Object-level authorization (post-v0.9.5)" with the canonical pattern, OWASP rationale, cache discipline, migration recipe, and djust check X008 reference; and "Security-class code defaults to fail-closed at every catch block" — when implementing auth/permission/validation code, catch Exception (not just the specific expected error), log via logger.exception, and default to deny. Failing-OPEN on unexpected exceptions is a security antipattern. Carries forward from v0.9.5-1b PR #1378's Stage 11 finding.

    6 new regression tests in python/tests/test_audit_ast.py::TestX008IDORShapeNeedsObjectPermission (positive case: classic IDOR shape triggers; negatives: has_object_permission override OK, check_permissions override OK, no permission_required no trigger, no URL-kwarg id no trigger; plus message-references-guide test).

    The split-foundation rollout is now complete. Issue #1373's IDOR class is structurally closed across mount and event surfaces; downstream consumers have the migration recipe and a static check to find affected views. Apps that override get_object() get end-to-end enforcement automatically.

[0.9.4] - 2026-05-06

Added

  • {% if %} blocks now emit dj-if boundary markers — Foundation 1 of #1358. Iter 1 of 3 toward the keyed VDOM diff for conditional subtrees (re-open of #256 Option A). At template-render time, every {% if %} block whose body contains element nodes is wrapped in HTML-comment boundary markers:

    <!--dj-if id="if-<prefix>-N"-->...rendered body...<!--/dj-if-->
    

    Browsers ignore HTML comments, so this is zero-observable-behavior — markers are framework-internal metadata for the upcoming Iter 3 (Rust VDOM differ) which uses them as keyed boundaries when conditionals flip.

    Marker shape: Option B (pair per Node::If). Nested elif chains produce nested marker pairs (the parser already nests an inner If(B) inside the outer's false_nodes). Pure-text conditionals (text-only true/false bodies) skip emission — text positions are sibling-stable already; the legacy <!--dj-if--> placeholder for false-no-else (issue #295) is preserved unchanged. HTML attribute context (issue #380) skips emission. The cond= attribute is intentionally OMITTED for safety (condition strings could contain -- or > that would close the comment early; Iter 3's differ keys off the id alone). {% csrf_token %} is treated as element-bearing (renders <input type="hidden">), so {% if request.method == "POST" %}{% csrf_token %}{% endif %} correctly emits the wrapping pair.

    ID generation: stable per-template counter if-<prefix>-N assigned at parse time via parser::assign_if_marker_ids walking the AST in document order. The <prefix> is an 8-hex-character source-derived hash (parser::parse_with_source(tokens, source)), so independently- parsed templates ({% extends %} parents, {% include %} partials, separately-loaded macros) get distinct prefixes and don't collide when their rendered HTML is composed in a single output buffer. Same source → same prefix → IDs are stable across re-renders. The {% for %}{% if %} pattern reuses the same id across loop iterations because the parser only sees one Node::If.

    VDOM parser (crates/djust_vdom/src/parser.rs) extended to preserve the new opening/closing markers as comment vnodes alongside the legacy <!--dj-if--> placeholder. The parser predicate accepts dj-if, dj-if<space-or-tab>..., and /dj-if; it rejects lookalikes like dj-iffy, dj-if-extra, dj-ifid="...". Client- side getNodeByPath path-fallback (12-vdom-patch.js) now mirrors this predicate via the new isDjIfComment helper, keeping client and server in lock-step. Public render_template / render_template_with_dirs strip ALL dj-if-family markers via strip_dj_if_markers helper — preserves the existing contract that public rendering yields clean HTML.

    What this enables (NOT in this PR):

    • Iter 2 (Foundation 2): client patch applier learns RemoveSubtree / InsertSubtree patch types.
    • Iter 3 (Capability): Rust VDOM differ recognizes dj-if boundaries; emits subtree-level patches when conditionals flip.

    Regression suite (post Stage 11 fix), totals across 5 files:

    • 30 cases in crates/djust_templates/tests/test_if_markers.rs (15 element-bearing / elif / nested / for-if / attribute-context cases + 4 cross-template uniqueness cases under cross_template_ids + 3 csrf_token / variable / raw-input classifier cases + 8 stability/ordering cases).
    • 11 cases in djust_vdom::parser::tests (legacy placeholder + boundary markers + 7 lookalike-rejection / whitespace-tolerance / prefixed-id / close-marker boundary cases).
    • 4 cases in parser::tests for prefix-deriving functions (parse_with_source shape / source-distinctness / source-stability / token-fallback).
    • 25 cases in python/tests/test_template_if_markers.py across TestElementBearingIfMarkers / TestPureTextSkip / TestPublicRenderTemplateStrips / TestIdStability / TestIdAssignment / TestAttributeContext / TestSiblingStability / TestIdPrefixUniqueness / TestCsrfTokenElementBearing.
    • 20 cases in tests/js/dj_if_comment_predicate.test.js (predicate matrix + path-fallback integration).
  • Client VDOM patch dispatcher learns RemoveSubtree + InsertSubtree patch types — Foundation 2 of #1358. Iter 2 of 3 toward keyed VDOM diff for conditional subtrees. The server doesn't emit these patch types yet (Iter 3 adds that), so this is zero-observable-behavior. When the upcoming Iter 3 differ recognizes dj-if boundaries (from Iter 1) and emits subtree-level patches on conditional flips, this dispatcher will route them correctly without a coordinated client+server release.

    Wire formats:

    • {type: "RemoveSubtree", id: "if-<prefix>-N"} — locates the <!--dj-if id="..."> open marker via TreeWalker-backed scan, walks forward depth-counting opens/closes, removes the entire bracketed range (markers + inner content) inclusive.
    • {type: "InsertSubtree", id: "...", html: "<!--dj-if-->...<!--/dj-if-->", path: [...], index: N, d: <parent dj-id?>} — parses the server-emitted HTML fragment via a <template> element so any <script> tags inside are inert by spec, then inserts at parent[index] using the same path/d resolution other child-targeting patches use.

    New helpers (all reused from Iter 1's isDjIfComment): _extractDjIfMarkerId, _findDjIfOpenMarker, _findDjIfCloseMarker (depth-counter, handles arbitrary nesting), _removeDjIfBracketedRange, _parseSubtreeHtml, applyRemoveSubtree, applyInsertSubtree. Dispatched from applySinglePatch via a short-circuit ahead of the path/d resolution so subtree patches don't try to resolve a non-applicable path. 25 regression cases in tests/js/dj_if_subtree_patches.test.js covering extractDjIfMarkerId (5) + marker-pair finder + nesting (5) + RemoveSubtree positive / empty-inner / nested-outer-removes-inner / nested-inner-leaves-outer / id-not-found / root-pair / missing-id (7) + InsertSubtree parses-and-inserts-at-index / appends-on-out-of-range / inert-script-via-template / missing-html / unresolvable-parent (5) + applySinglePatch dispatch wiring (3).

    What this enables (NOT in this PR): Iter 3 (Capability): Rust VDOM differ recognizes dj-if boundaries from Iter 1; emits these patch types when conditionals flip.

  • Keyed VDOM diff for {% if %} conditional subtrees (#1358; closes #256 Option A; capability of v0.9.4-1). Iter 3 of 3 — the iter that actually fixes the bug. After this PR, the long-standing class of {% if %}-breaks-VDOM-patching bugs that has plagued djust for over 3 months is eliminated. The Rust VDOM differ now recognizes <!--dj-if id="if-<prefix>-N"-->...<!--/dj-if--> boundary markers (emitted by Iter 1 template renderer, PR #1363) as KEYED units in the diff algorithm.

    When conditionals flip, the differ emits the new patch types from Iter 2 (PR #1364):

    • OLD has boundary id=X, NEW does notRemoveSubtree { id: X }. Client locates the marker pair by id (NOT by position) and removes the bracketed range.
    • NEW has boundary id=Y, OLD does notInsertSubtree { id: Y, path, d, index, html }. Client parses the full marker-pair HTML (Shape A) via inert <template>.innerHTML and inserts at the parent / index resolved via the same path/d resolution other child-targeting patches use.
    • Both have boundary id=Z → recurse into the inner body via dj_if_pre_pass_inner. The recursion handles arbitrary nesting cleanly, including {% if %}/{% elif %}/{% else %} cascades where the outer marker is matched in both OLD and NEW but the body introduces (or removes) an inner boundary marker. Standard intra- subtree diff fires for the inner content (SetText, SetAttr, etc.) only when the body has NO nested boundaries.

    Position-based path tracking is BYPASSED within boundaries: the id normalizes positions, so adding or removing a boundary no longer cascades into mis-targeted patches in surrounding siblings. Non- boundary siblings are paired by relative position AMONG non-boundary siblings — the conditional's presence/absence doesn't shift their relative order.

    The 17.5%-error-rate tab-switch regression in a downstream consumer (cited in #1358's body) no longer reproduces. The recovery-HTML / page-reload fallback path is no longer triggered by {% if %} flips.

    Recursive pre-pass for {% if %}/{% elif %}/{% else %} cascades (Stage 11 finding on PR #1365 — capability iter). The first iteration of this fix iterated matched-id body children element- by-element via diff_nodes, treating any nested boundary markers as ordinary VNodes. That produced overlapping patches when a cascade introduced or removed nested boundaries:

    • Top-level step 2 emitted InsertSubtree(B) correctly.
    • Top-level step 3 ALSO emitted Replace + InsertChild patches for the same content (because element-by-element pairing saw B's markers as ordinary comment nodes and B's content as new sibling).
    • Both applied = corrupt DOM with duplicated content and mismatched markers.

    The recursive pre-pass closes this gap: when matched-id A's body has nested boundaries, dj_if_pre_pass_inner recursively runs on the body slice. Each recursion level handles only its OWN top-level pairs (via the new find_top_level_dj_if_pairs helper), so nested pairs are discovered at the recursion level that descends into their containing boundary. No more overlap; no more duplicate patches; arbitrary nesting (3+ levels) handled coherently.

    Backwards-compatible: Apps using the d-none workaround documented in CLAUDE.md and downstream repos continue to work identically — the workaround sidesteps {% if %} entirely. Apps using the legacy bare <!--dj-if--> placeholder for false-no-else conditionals (issue #295) take the existing diff path unchanged (those placeholders have NO id and don't trigger the new keyed pre-pass). The pre-pass only fires when at least one sibling list contains an id-bearing boundary marker.

    Implementation in crates/djust_vdom/src/diff.rs:

    • New helpers: dj_if_open_id, is_dj_if_close, find_top_level_dj_if_pairs (depth-counter that returns ONLY outermost pairs at the current slice level — nested pairs are discovered when the recursion descends), render_dj_if_boundary_html (serializes boundary slice for InsertSubtree.html), build_excluded_mask.
    • New dj_if_pre_pass runs at diff_children entry; delegates to dj_if_pre_pass_inner which carries old/new offsets so absolute parent-children indices propagate correctly across recursion levels (DOM parent stays the same — markers don't create container elements).
    • Returns Some(patches) when boundaries are present (caller short-circuits its keyed/indexed diff), None otherwise (caller proceeds unchanged or, in the recursive case, falls back to element-by-element pairing of the body slice).
    • Predicates mirror parser-side at crates/djust_vdom/src/parser.rs:494-499 and JS-side at python/djust/static/djust/src/12-vdom-patch.js:38-43.

    Wire format (locked by Iter 2):

    • {type: "RemoveSubtree", id: "if-<prefix>-N"}
    • {type: "InsertSubtree", id: "...", path: [...], d: "<parent dj-id?>", index: N, html: "<!--dj-if id=...-->...<!--/dj-if-->"}

    Limitation noted in code: when non-boundary siblings carry dj-key attributes AND reorder within their relative slot, the position-based pairing of non-boundary children can produce suboptimal patches. Production templates don't typically reorder elements across {% if %} boundaries; if a regression surfaces, the pre-pass can be extended to delegate non-boundary children to diff_keyed_children when any of them have keys.

    Out of scope (deferred to v0.10): wholesale-replace heuristic for same-id matched boundaries (e.g., when inner content differs by >X%); LIS within boundary bodies; relaxing d-none workaround documentation in CLAUDE.md / downstream repos.

    Regression suite: 19 cases in crates/djust_vdom/tests/test_dj_if_keyed_diff_1358.rs covering: two separate {% if %} blocks flipping (the renamed Case 1), conditional flip-off, conditional flip-on, same-id inner text change (recurses, NOT subtree replace), same-id identical inner (0 patches), nested boundaries inside DOM elements (inner flip leaves outer alone), sibling-shift regression with DIFFERENT boundary span lengths (3 vs 1 inner children — exercises the position-cascade class explicitly), empty boundary same id (0 patches), empty boundary different ids (Remove + Insert), JSON wire-format shape comparisons via serde_json::Value for RemoveSubtree / InsertSubtree / d omission when None (tightened from substring contains per Stage 11 finding), backward compat with legacy bare <!--dj-if--> placeholder, end-to-end via parse_html (proves parser-side and differ-side predicates agree), and 5 NEW elif-cascade cases (Stage 11 finding on this PR): A → elif-B flip (cascade introduces nested marker), elif-B → A flip (cascade collapses nested marker — symmetric direction, SHOULD-FIX #4), A → else with double-nested matched ids (no subtree-flip patches when both outer+inner ids match), cascade with extra static siblings (footer SetText path must use NEW tree's absolute index, not OLD's), 3-level cascade (A → B → C nesting introduced atomically — proves recursive pre-pass handles arbitrary depth).

    All 19 dj-if keyed-diff tests pass. All Rust tests pass. All Python tests pass. All 1559 JS tests pass.

    Closes the capability half of v0.9.4-1 milestone (Iter 3 of 3). Foundation 1: PR #1363 (template markers). Foundation 2: PR #1364 (client patch types). Stage 11 must-fix and should-fix findings from this PR's review addressed in commit on this same PR.

Fixed

  • _sortPatches now orders RemoveSubtree / InsertSubtree BEFORE path-based child ops — the actual root cause of #1370. _sortPatches assigned id-based patches to the default phase (3), so on the short-path (≤10 patches) the batch ran as [RemoveChild, InsertChild, SetAttr, RemoveSubtree, InsertSubtree]. The server's path-based RemoveChild/InsertChild indices reflect the NEW tree's positions (after subtree ops applied). Running RemoveChild against the still-old DOM targeted the wrong child → silent DOM corruption that accumulated across tab switches until client and server state fully desynced. Fix: assign RemoveSubtree phase -2 and InsertSubtree phase -1, so both sort ahead of RemoveChild (phase 0). The long-path (>10 patches) was already pre-separating id-based patches (rc3 fix); this unifies short and long paths on the same ordering. Diagnosed via djust-browser MCP inspecting WS frames against a production reproducer.

  • RemoveSubtree / InsertSubtree are now idempotent w.r.t. the desired end-state (#1370 rc8). After many tab switches the server's VDOM diff baseline could drift, occasionally emitting a RemoveSubtree for a marker already removed in a prior patch (or an InsertSubtree for a marker already present). 19/20 patches succeeded but the one stale patch failed → client triggered recovery-HTML → page reload. Fix: both patch handlers now treat "already in the desired state" as success. RemoveSubtree with a missing marker is a no-op (returns true); InsertSubtree with an already-present marker is a no-op (doesn't duplicate content). Symmetric. Matches the semantics of RemoveChild on an already-removed node in the standard patch set.

  • Double-nested dj-root eliminated (#1370 final). self._rust_view.render() produces HTML that already includes its own <div dj-root>...</div> wrapper. The Step 3 replacement was inserting that as the innerHTML of the shell's dj-root → double nesting. Fix: replace the shell's ENTIRE <div dj-root>...</div> element (opening tag through closing tag) with liveview_html. Single dj-root level, correct marker IDs from self._rust_view, no path index drift.

  • Handler metadata <script> no longer injected inside dj-root (#1370 final fix). _inject_handler_metadata was appending a <script> element inside the dj-root content on initial HTTP render. The server's VDOM doesn't include this script → every sibling path after the script was shifted by +1 → all path-based patches failed with "parent: SCRIPT". Fix: inject handler metadata into the full page HTML (before </body>) AFTER the dj-root replacement, so it lives outside the VDOM-tracked subtree. This was the actual root cause of the "15/17 patches failed" pattern — not marker IDs (rc4/rc5 fixed those) nor the extension (confirmed by disabling it).

  • Architectural fix: single RustLiveView for HTTP + WS render (#1370 final). render_full_template now renders dj-root content via self._rust_view (the SAME instance the WS path uses), guaranteeing marker IDs match by construction. The page shell is still rendered by a temp instance, but the shell's dj-root innerHTML is replaced with self._rust_view.render(). No marker stripping, no first-WS overhead, no mismatch possible. Removes the architectural debt of two RustLiveView instances rendering the same view.

  • Marker ID mismatch between HTTP render and WS diff resolved (#1370 re-open). render_full_template created a temporary RustLiveView(self._full_template) whose template-source hash differed from the VDOM-tracked template's hash (self.get_template()). HTTP-rendered DOM had markers with prefix A; WS differ emitted patches with prefix B → "RemoveSubtree: open marker not found" → recovery HTML → page reload. Fix: strip <!--dj-if--> markers from the initial HTTP render so the client DOM starts marker-free. On first WS render_with_diff, the differ sees "NEW has markers, OLD doesn't" → emits InsertSubtree with the correct (VDOM-tracked) IDs. The non-inheritance path (self.render()) was already correct (same RustLiveView instance as WS path). This only affected projects using {% extends %} template inheritance.

  • RemoveSubtree / InsertSubtree patches no longer crash groupPatchesByParent (#1370 follow-up). v0.9.4rc2 fixed the hooks TDZ but exposed a second crash: TypeError: Cannot read properties of undefined (reading 'slice') at groupPatchesByParent in 12-vdom-patch.js. The Iter 3 patches (RemoveSubtree, InsertSubtree) don't carry a path field — they locate their target by marker id. groupPatchesByParent assumed all patches have path. Fix: filter out id-based patches and apply them directly via applySinglePatch BEFORE the path-grouped batching pass. Without this, any {% if %} block flip (the exact feature v0.9.4-1 shipped) triggered the TypeError → recovery HTML → page reload.

  • HOTFIX: v0.9.4rc1 hooks TDZ regression (#1370). v0.9.4rc1 shipped a bundled client.js that threw Uncaught ReferenceError: Cannot access 'G' before initialization (G is the minified _activeHooks) on every page load and every WS patch. Module 19 (19-hooks.js) is concatenated after the bootstrap call at bundle line ~7842; let _activeHooks was in TDZ when _ensureHooksInit was invoked from earlier modules' djustInit (the synchronous-init branch fires when document.readyState !== 'loading'). Fix: letvar for _activeHooks and _hookIdCounter in src/19-hooks.js:54-56 (hoisted, no TDZ). Bundle rebuilt; new regression test in tests/js/bundle-init-no-tdz.test.js (2 cases) loads the bundled client.js in a fresh JSDOM context with readyState === 'complete' and asserts no ReferenceError on init — verified to FAIL against the rc1 bundle and PASS against the fixed bundle. Why PR #1359 (eslint cleanup) missed this: the missed-revert was caught via vitest import-order tests that simulate DECLARED-EARLY-USED-LATE patterns, but those tests do not simulate bundle-concat-order execution; _activeHooks is the inverse (DECLARED-LATE-USED-EARLY in the concat). The new bundle-init regression test catches the class structurally.

  • dj-transition now respects CSS transition-duration instead of a hard-coded 600ms fallback (#1348). The fallback timeout is auto-derived from the element's computed transition-duration + transition-delay (longest pair across all transitioning properties) plus a 50ms grace window. For multi-property transitions, expected transitionend events are counted from transition-property and cleanup runs only after all have fired — otherwise the first-finishing property would cut off slower ones. _FALLBACK_MS_DEFAULT (600ms) is used only when computed-style reading fails or yields zero. Same auto-derivation extended to dj-remove. Source-only commit; bundle (client.js) rebuild deferred per #1351 (392 pre-existing eslint warnings block --max-warnings 0).

  • db.notifications exits cleanly on permanent failures instead of retrying forever. Background — incident 2026-05-05: a 3.5-day-old djust deploy missing the optional psycopg[binary]>=3.2 dependency had _run() retrying _connect() every 1 second forever (~302,000 attempts), accumulating 15.4 GiB of anonymous heap from un-reaped asyncio Task / coroutine closure state. The kubelet hit memory pressure, transitioned to NodeNotReady for ~7 seconds, which was enough for cnpg to fail over the postgres-cluster primary → 3-minute platform outage. Fix: when _connect() raises DatabaseNotificationNotSupported (missing psycopg or non-postgres engine), treat as PERMANENT — log once at WARNING with operator-actionable wording, set _stopping = True, fire _ready_event, return from the loop. Process restart re-enables once the cause is fixed. Transient failures (ConnectionRefusedError, OSError, timeout, etc.) retain their 1-second-backoff retry behaviour. 2 regression tests in python/djust/tests/test_notifications_permanent_failure.py (test_run_exits_immediately_on_permanent_failure, test_run_retries_transient_connect_failures).

  • In-memory state backend no longer panics on concurrent same-session HTTP renders (#1353). When two HTTP requests for the same (session, view_path) pair shared a cached RustLiveView (the in-memory backend returned the same Python reference on cache hits), concurrent &mut self Rust methods on the shared view would collide inside Rust's RefCell::borrow_mut and surface as RuntimeError: Already borrowed (a downstream consumer observed 17.5% 500-rate at concurrency 2). The race spanned more than the _sync_state_to_rust mutation calls — render() itself holds &mut self across template evaluation, and Context::resolve_dotted_via_getattr (crates/djust_core/src/context.rs) wraps Python::with_gil so the embedded getattr can yield the GIL inside an active mutable borrow. Any peer thread entering an &mut self method during that window panicked. Fixed by switching InMemoryStateBackend.get() to return an isolated serialize_msgpack / deserialize_msgpack clone of the cached view (option 2 of three suggested in the issue body), mirroring the RedisStateBackend contract — which already deserialized fresh on every read. With each caller holding its own RustLiveView instance, no two threads can share a Rust &mut self borrow and the race class is eliminated at the source. No Python-side lock is needed. New regression cases in TestInMemoryGetReturnsIsolatedView (4 cases — clone identity, state preservation, mutation isolation, concurrent get) and TestConcurrentRenderNoBorrowError (2 cases — concurrent render with GIL-yielding sidecar, concurrent update_state) in python/tests/test_rust_bridge_concurrent.py.

  • State backend honours top-level DJUST_STATE_BACKEND / DJUST_REDIS_URL settings (#1354). Previously BackendRegistry only consulted DJUST_CONFIG["STATE_BACKEND"], so projects configuring via top-level Django settings (e.g. DJUST_STATE_BACKEND = "redis://localhost:6379/0") were silently downgraded to in-memory with no warning. Now the registry layers top-level aliases on top of DJUST_CONFIG (DJUST_CONFIG still wins when both are set — backwards-compatible). URL-shaped values (redis://, rediss://, redis+sentinel://) are auto-translated to backend_type="redis" plus REDIS_URL=<url>; the prefix list lives in BackendRegistry._REDIS_URL_PREFIXES. When DEBUG=False and the resolved backend is the default (in-memory), djust.utils.BackendRegistry.get now emits a logger.warning flagging the production misconfig — multi-process deployments lose state across replicas. unix:// URLs are left as a TODO follow-up because the underlying redis-py client takes Unix sockets via a different parameter name. New regression cases in TestTopLevelStateBackendSetting (6 cases) and TestDjustConfigRegression (3 cases) in python/tests/test_state_backend_config.py.

Changed

  • Redis state-backend cache keys now include the template-source hash for automatic deploy-time invalidation (#1362). Previously operators had to set REDIS_KEY_PREFIX = f"djust:{BUILD_ID}:" (or otherwise rotate the prefix on every deploy) to ensure cached RustLiveView state from a prior deploy didn't act as a stale diff baseline for the new render. Easy to forget; production failure mode was patches failing on WS reconnect post-deploy → recovery HTML unavailable → forced page reload. The framework now reuses the 8-hex template-source hash from parse_with_source (PR #1363, Foundation 1 of #1358) as part of the cache key:

    djust:state:<session>_liveview_<view_path>[_<query_hash>]_t<template_8hex>
    

    When ANY operator edits a template (whitespace, attribute, structural change), the per-template hash flips → cache key flips → next reconnect misses the cache → fresh state is constructed cleanly, no stale baseline. Zero operator config; no env var to set, no setting to flip. Backwards compat: existing cached entries with the old key shape become unreachable on the deploy that ships this — bounded by TTL (default 1 hour). Multi-template caveat: the cache key uses the PRIMARY template's hash; sub-template-only changes via {% include %} / {% extends %} parents that don't alter the primary's source bytes won't invalidate by themselves (operators can djust clear --all for immediate invalidation in that edge case). Both consumers of the template hash (parser-side <!--dj-if id="if-<prefix>-N"--> markers and the new cache-key slot) flow through the single djust_templates::parser::template_hash_hex Rust helper, so they cannot drift. 12 regression tests in python/tests/test_template_hash_redis_cache.py (cache HIT/MISS behavior, multi-session isolation, cross-deploy reproducer, PyO3 boundary equality, multi-template caveat with real Django include resolution, plus 2 perf-regression tests verifying the cache HIT path no longer pays the get_template() cost). 3 new Rust unit tests in crates/djust_templates/src/parser.rs (hash consistency, distinguishability, marker-ID prefix equality). Existing test_vdom_cache_key.py updated for the new key shape.

    Stage 12 (address-findings) refinements on the same PR:

    • Cache HIT perf-regression fix. First implementation hoisted self.get_template() to before the cache lookup so the per-template hash could be derived. That regressed the cache HIT path: pre-#1362 a WS reconnect with a warm cache never called get_template(), post-#1362 every reconnect ate the Django template loader + inheritance resolution cost. Stage 12 introduces _get_cached_template_hash_slot() which memoizes the _t<8hex> slot on the view CLASS so the cost is paid ONCE per class lifetime; subsequent calls return the slot in O(1) without touching get_template(). Cache HITs now match the pre-#1362 perf profile.
    • Multi-template caveat test rewritten. First version called compute_template_hash(primary_src) twice on the same input and asserted equality — a tautology already covered by test_compute_template_hash_stable_across_rebuilds. Stage 12 rewrites it to set up real parent.html + child.html files, rewrite child.html between two renders, verify the rendered output ACTUALLY differs (so the include is being re-resolved), then assert the primary's source bytes hash to the same _t<8hex> slot. The test would FAIL on a hypothetical Option B (composite-hash) implementation, which is the discipline-correct way to demonstrate Option A's caveat (Action #1200).
  • Deployment guide additions for production gaps surfaced from a downstream consumer (#1362). Added three subsections to docs/website/guides/deployment.md:

    • Recovery HTML semantics: per-consumer one-shot, fresh-consumer- after-reconnect = no recovery state, multi-task amplification of the user-visible impact. Cross-references v0.9.4-1's keyed conditional VDOM diff (PR #1365 / #1358) as the architectural escape hatch.
    • Quantified Daphne → Uvicorn benchmark: 6.4× rps / 8.3× p99 on health-check endpoints from a 1 vCPU / 2 GB Fargate task with the a representative downstream-consumer app. Per-app variance disclaimer included.
    • Production checklist: 8-line copy-pasteable recipe linking to each relevant subsection of the guide; inserted as the first subsection of the existing Deployment Checklist.

    Also updated the Redis state-backend coverage to note that the template-hash-keyed cache (PR #1367, Iter 1 of v0.9.4-2) makes the previous manual REDIS_KEY_PREFIX = f"djust:{BUILD_ID}:" pattern obsolete. Pure docs PR — no code changes; the framework behavior is unchanged from Iter 1.

  • Bundled client.js and debug-panel.js are now eslint-clean (#1351). The 393 pre-existing eslint warnings in client.js (and 32 in debug-panel.js) have been resolved across the ~70 source modules in python/djust/static/djust/src/ and src/debug/. Breakdown of the fixes:

    • Auto-fixed: 222 (prefer-const, no-var) via eslint --fix python/djust/static/djust/src/.
    • Targeted disables: 116 in client.js sources + 25 in debug-panel.js sources (security/detect-object-injection, all on internal data structures — typed for-loop indices, controlled object-literal lookups, DOM-controlled keys like field.name. djust already validates against UNSAFE_KEYS for the real prototype-pollution attack surface).
    • Refactored: 16 no-unused-vars (mostly catch-error parameters _-prefixed; 2 functions inlined as dead code, 1 redundant parameter renamed). 1 security/detect-non-literal-regexp for server-controlled route patterns in 18-navigation.js.
    • Cross-module guards: 4 prefer-const reverted to let for cross-file reassigned globals (liveViewWS, clientVdomVersion, _eventRefCounter, _isBroadcastUpdate) that ESLint's per-file scope incorrectly suggests as const — auto-fix had broken the transport-switch + broadcast paths until reverted.
    • ESLint config: catch-error parameters now respect caughtErrorsIgnorePattern: "^_"; concat-fragment source modules (00-namespace.js, 21-guard-close.js, src/debug/*.js) are correctly identified as bundle inputs that don't parse standalone. The --max-warnings 0 flag is now enforced on the eslint pre-commit hook (.pre-commit-config.yaml) — contributors no longer need SKIP=build-js,eslint to commit JS source changes. Unblocks the bundle rebuild deferred from PR #1357 (dj-transition fix #1348).

[0.9.3rc2] - 2026-05-04

Changed

  • CodeQL workflow now cancels superseded analyses on rapid PR pushes (#1340). Added concurrency: { group: ${{ github.workflow }}-${{ github.ref }}, cancel-in-progress: true } to .github/workflows/codeql.yml. The latest commit's analysis is what matters; older runs are obsolete and only add noise to the PR check list. Investigation in #1340 surfaced that the v0.9.3 drain's "stale CodeQL check-run" framing was a misdiagnosis — most "stale CodeQL fail" check-runs were real GitHub Advanced Security alerts, not stale leftovers. The --admin merge requirement comes from the 1-approving-review rule (solo maintainer can't self-approve), not from CodeQL. This concurrency block reduces the run-list noise that fueled the misdiagnosis without changing merge behavior. Triage of the 8 real open CodeQL alerts (1 high-severity) tracked in #1343.

Fixed

  • _mount_one now returns a consistent 5-tuple from every path (#1343). The except Exception branch in LiveViewConsumer._mount_one (websocket.py:2469) returned a 4-tuple while every other path returned a 5-tuple (ok, payload, err, nav, push_events). The single caller in handle_mount_batch unpacks 5 values; the mismatch raised ValueError: not enough values to unpack, masking the per-view error in the batch failed[] plumbing. Surfaced by CodeQL py/mixed-tuple-returns alert. Returns [] for push_events from the exception path. 1 regression test in test_sw_advanced.py::TestMountBatch::test_mount_one_returns_5_tuple_on_unhandled_exception.

  • deploy_cli.py no longer has a bare except: pass for transient status-poll errors (#1343). Surfaced by CodeQL py/empty-except alert. Replaced with logger.debug("status poll failed; retrying", exc_info=True)

    • an explanatory comment. CLAUDE.md security rule #5 forbids bare except: pass framework-wide.
  • python/djust/tests/ now included in make test-python + check-test-coverage target (#1339). The Makefile's test targets used explicit pytest paths (tests/ python/tests/) which override pyproject.toml's testpaths, silently excluding python/djust/tests/ (2,734 tests across 100+ files). Added the missing directory to test-python, test-python-parallel, and the background test target. New make check-test-coverage target prevents recurrence by verifying every test directory is collected by CI. Verified by make check-test-coverage and the 2,734 newly-collected existing tests.

  • @reactive now fails at class-definition time on classes missing update() (#1287). The @reactive decorator previously guarded self.update() with hasattr(self, 'update'), silently no-opping when the host class lacked the method. It now uses __set_name__ to validate at class-definition time, raising TypeError with a clear message. The _ReactiveProperty descriptor also calls update() automatically for both default and custom setters. 6 regression cases in test_decorator_reactive_requires_update.py.

  • @background docstring now documents return-value contract (#1288). The decorator's docstring mentions that handler return values are discarded and points users to @action + _action_state for result tracking. 2 regression cases in test_background_return_value_docs.py.

  • @computed memoized cache is now thread-safe (#1289). The @computed decorator's memoized form previously mutated the per-instance cache dict without synchronization, creating a race window between threads (e.g. a @background callback and template rendering). A per-instance threading.Lock now protects the check-then-act cache mutation. 3 regression cases in test_decorator_computed_thread_safety.py.

  • New make check-handler-contracts linter (#1290). scripts/check-handler-contracts.py cross-references template-tag _event emit defaults against component/mixin handler method names, catching #1275-class (stale/typo'd emit default) bugs at pre-push time. 44 emit defaults (26 framework, 18 app-level) validated clean. Added to pre-push hook. 7 test cases in test_check_handler_contracts.py.

  • dj-form-pending now visible on WebSocket path (#1315). sendEvent() was fire-and-forget — it returned true synchronously, causing handleEvent() to resolve immediately on the WebSocket path. _setFormPending(false) fired before any browser repaint, so the pending state (spinner, disabled inputs, hidden labels) was never visible. sendEvent() now returns a Promise that resolves when the server's response (patch/noop/error with matching ref) arrives, via a new _pendingEventResolvers Map alongside the existing pending-event tracking. All clear sites resolve pending resolvers on disconnect. 2 regression cases in dj-form-pending.test.js (WebSocket path block).

  • @server_function no longer hard-codes auth check (#1316). dispatch_server_function previously had an inline anonymous-user check that rejected all unauthenticated callers regardless of the view's login_required setting. The check is removed — check_view_auth (view-level login_required / permission_required) and check_handler_permission (handler-level @permission_required) now govern auth, matching the ADR-008 contract. @server_function no longer requires authentication by default. 4 regression cases in test_server_functions.py.

  • #1281 regression tests moved to python/tests/ for CI coverage (#1325). test_skip_render_private_state.py (9 tests) was in python/djust/tests/ which is excluded from the explicit paths in make test-python and the CI workflow. Moved to python/tests/ so CI collects the tests on every run.

[0.9.3rc1] - 2026-05-02

Fixed

  • Private state mutations no longer cause false noop (#1281). _snapshot_assigns() previously skipped all _-prefixed attrs via k.startswith("_"), so a handler that mutated only private state (e.g. self._orders) produced identical pre/post snapshots → the render was skipped → the client received a noop frame. The filter now uses view_instance._framework_attrs membership (captured at __init__) to distinguish framework-internal _-prefixed attrs from user-defined private attrs set in mount() or event handlers. 9 regression cases in test_skip_render_private_state.py.

  • _action_state now persists across WebSocket reconnects (#1284). The @action decorator populates _action_state[action_name] with {pending, error, result} so templates can reference {{ action_name.error }}. Previously _action_state was initialized before _framework_attrs capture in __init__, putting it in the framework-internal set that _snapshot_user_private_attrs and _restore_private_state exclude. It now initializes after the capture, so the standard user-private save/restore cycle handles it automatically. 6 regression cases in test_action_state_reconnect.py.

  • Snapshot truncation warning for large lists/dicts (#1285). _snapshot_assigns() now emits a one-shot logger.warning per view class when a list has ≥100 items or a dict has ≥50 keys. These containers have truncated content fingerprints (list: only (id, length); dict: only len(v) instead of a key tuple), so in-place mutations inside them are not detected by auto-diff. The warning tells developers to use set_changed_keys() or assign a new reference. 10 regression cases in test_snapshot_truncation_warning.py.

  • Change-detection unified: identity snapshots now use _framework_attrs (#1286). The push_commands-only auto-skip path (#700) had identity snapshots at lines 3001 and 3102 that still used k.startswith("_") to filter attrs, while _snapshot_assigns() was updated in #1281 to use _framework_attrs membership. This meant the two detection paths could disagree on whether private state changed. Both identity snapshots now use _framework_attrs, closing the dual-path discrepancy (weakness #8 from the lifecycle audit). 8 regression cases in test_change_detection_unified.py.

[0.9.2] - 2026-05-02

Fixed

  • markdown and nh3 moved from optional extras to core dependencies. djust.components.templatetags.djust_components eagerly imports both packages at Django template engine startup, making them hard requirements for any project with djust in INSTALLED_APPS. Previously they were only in the [components] extra, causing ModuleNotFoundError when the extra wasn't explicitly installed. Caught during djustlive scaffold deployment to k8s.

This stable release rolls up all v0.9.2rc1 + rc2 fixes (audit-driven Phase 1 work across audits A-G; 11 of 12 🔴 originals from the "downstream consumer surfaced" cohort closed; #1281 deferred to v0.9.3 as documented known issue). See [0.9.2rc2] below for the full audit-cohort delta.

[0.9.2rc2] - 2026-05-01

Fixed

  • dj-transition now accepts 1-token short form, matching documented grammar. Closes #1273. The dj-transition-group docs at 43-dj-transition-group.js:22-23 advertise short form like dj-transition-group="fade-in | fade-out" where each half can be a 1-token spec — but _parseSpec at 41-dj-transition.js:45-60 required 3 tokens, so the ENTER side was silently rejected and no animation fired. Fix: extend _parseSpec to accept 1-token form, return {single: <class>}; _runTransition handles single by applying the class on next frame and waiting for transitionend. 2-token form remains rejected as ambiguous (matches dj-remove). 2 new regression cases in TestParseSpecAcceptsShortForm + behavioral test (tests/js/dj_transition.test.js).

  • AsyncResult now serializes to a dict templates can navigate. Closes #1274. assign_async() returns AsyncResult instances that templates expect to read as {{ users.loading }}, {{ users.ok }}, {{ users.failed }}, {{ users.result }}, {{ users.error }}. Before this fix, neither normalize_django_value nor DjangoJSONEncoder.default had an AsyncResult branch — the value fell through to str(), producing "AsyncResult(loading=True, ...)" which templates couldn't navigate. Result: every assign_async demo rendered blank. Fix: new AsyncResult.to_dict() method + register in both serializer paths; normalize_django_value recurses into the dict so a non-primitive result payload (Django Model, datetime, Decimal nested in result) is normalized too. 11 regression cases in python/djust/tests/test_async_result_serializer.py.

  • Form submit now flushes pending debounced dj-input handlers before dispatching. Closes #1278. Text/email/password inputs with dj-input defaulted to 300ms debounce; a user who typed and immediately clicked submit raced the submit handler past the pending input events. Views that depended on server-side state populated by dj-input handlers (e.g., WizardMixin's wizard_step_data) saw stale state at submit time. Fix: debounce() now exposes a .flush() method; new _flushPendingDebouncesInForm(form) iterates the form's [dj-input] descendants and flushes any pending wrappers; _handleDjSubmit calls it before reading FormData / dispatching. 4 regression cases in tests/js/dj_submit_debounce_flush.test.js.

  • dj-dialog client-close (ESC/backdrop/dialog.close()) now syncs back to server. Closes #1267. Previously dj-dialog was one-way (server→client); the user closing a dialog client-side left server state believing it was still open. Re-opening from the server became a no-op because re-asserting dj-dialog="open" wasn't a value change. Fix: new dj-dialog-close-event="..." attribute opts into a native close event listener that dispatches the configured event name to the server. Idempotent across re-syncs (WeakMap guard); reads attribute at fire time so morph updates take effect. 5 new regression cases in tests/js/dj_dialog.test.js.

  • SSE transport: EventSource and dispatch POST now send Django session cookie. Closes #1277. Authenticated views over SSE failed check_view_auth on every mount because the EventSource GET (and the message POST) didn't carry credentials. Result: infinite mount→navigate loop on authenticated views. Fix: 03b-sse.js:69 opens EventSource with {withCredentials: true}; 03b-sse.js:367 sendMessage POST sets credentials: 'include'. 2 new regression cases in tests/js/sse-transport.test.js.

  • mount() lifecycle: queued async work and push events are now drained after the mount frame. Closes #1280 (assign_async() / start_async() called from mount() never resolved over WebSocket — view stayed at initial loading-state HTML forever) and #1283 (push_event() called from mount() or on_mount hooks queued events that never reached the client). Both root at the same site: LiveViewConsumer.handle_mount() ended with send_json(response) without draining _async_tasks or _pending_push_events. The fix mirrors the established pattern in handle_event() / _flush_deferred_activity_events(): send the response frame, then drain push events, then dispatch async work. 3 regression cases in TestHandleMountSourceShape (python/djust/tests/test_handle_mount_drains_queues.py).

  • data_table integration restored over WebSocket — emit defaults renamed to match on_table_* mixin handler convention. Closes #1275 (tag emitted 23 event names that didn't match any handler), #1291 (pagination handlers entirely missing from the mixin), #1279 (handlers mutate state but never refresh rows). Single root cause: the WS dispatcher does exact-match getattr(view, event_name, None) (websocket_utils.py:173), but the tag-emit defaults previously used bare table_* strings while DataTableMixin uses on_* Phoenix-style handler names — so every default WS interaction returned "no handler found". Fix: rename tag-emit defaults across 4 files (92 lines: templatetags/djust_components.py, mixins/data_table.py class-level attrs + _PRE_MOUNT_TABLE_CONTEXT, components/rust_handlers.py, templatetags/_forms.py); add on_table_prev / on_table_next handlers (clamped to [1, table_total_pages]); call refresh_table() from sort/search/filter/page/prev/next handlers (selection handler deliberately exempt — UI state). 15 regression cases in TestDataTableEmitToHandlerCrossReference, TestPaginationHandlersExist, TestRowAffectingHandlersCallRefresh (python/djust/tests/test_data_table_handler_contracts.py). Subclasses that overrode table_X_event class attrs are unaffected — only the bare-default path was broken.

  • @action no longer re-raises after recording exception state. Closes #1276. The decorator's docstring promised templates could read {{ <name>.error }} after an exception, but the implementation re-raised — the dispatcher's exception-frame path then bypassed the re-render and the template never saw the recorded error field. Fix: catch Exception (not BaseException), record state, log at ERROR level via logger.exception, return None. BaseException subclasses (KeyboardInterrupt, SystemExit, GeneratorExit) still propagate by Python convention. 8 new regression cases in TestActionExceptionDoesNotPropagate + TestActionSuccessRecordsState

    • TestActionLazyInitializesActionState (python/djust/tests/test_action_decorator_contract.py); 6 existing tests in test_action_decorator.py updated to the new contract. Docstring at decorators.py:262-272 rewritten to match. Behavior change: code that wraps @action calls in try/except to handle the re-raise now sees a clean return. Mirror the old behavior by re-raising explicitly inside the handler.

Documentation

  • Lifecycle Coverage Audit + Decorator/Tag Contract Audit (docs/audits/lifecycle-2026-05.md, docs/audits/decorator-contract-2026-05.md). Two companion audit docs modeled on the v0.9.2-4 VDOM audit. Document the canonical state-type × lifecycle-hook matrix and the decorator/tag-name dispatch contract, surfaced from 10 downstream consumer bug reports (#1267, #1273-#1281). The lifecycle audit catalogues 8 ranked weaknesses including the central control-flow gaps in mount() (#1280, #1281). The decorator/tag audit catalogues 8 weaknesses including the data_table tag emitting 23 event names that don't match any DataTableMixin handler (#1275 generalized). Each audit ships with a 4-phase improvement roadmap, test gaps, strategic observations, and a companion canon update for CLAUDE.md / PR-checklist. Pre-staged issues filed for each not-yet-tracked weakness (#1283-#1291). Audit-driven Phase 1 fixes blocking v0.9.2 stable will land in the v0.9.2-5 drain bucket; Phase 2/3 fixes targeted for v0.9.3.

  • Production Deployment guide extended with Tier 1/2/3 patterns (docs/website/guides/deployment.md). Adds 8 new sections to the canonical deployment guide based on patterns surfaced from real-world djust deployments:

    • Channel Layer (cross-process push) — separate concern from DJUST_STATE_BACKEND, required when any view uses push_to_view, presence, or cursor tracking.
    • Database Connection Pooling — three-layer guidance (CONN_MAX_AGE, PgBouncer, RDS Proxy), with the LISTEN/NOTIFY caveat for transaction-mode pooling.
    • Celery Integration — broker choice (Redis vs SQS), pool choice (prefork vs gevent), beat-singleton invariant, gevent monkey-patch gotcha, queue-depth-based worker auto-scaling.
    • Static and Media Files — cloud-agnostic CDN options, S3 + CloudFront config, ASGI_SERVE_STATIC=False opt-out for offloading static-file serving from the ASGI server.
    • WebSocket stickiness on AWS ALB — the simpler "stick on Django sessionid" pattern as an alternative to a custom application-set cookie.
    • Sizing and Scaling Tiers — concrete vCPU/RAM recommendations indexed to concurrent active users (≤50, 50-500, >500), with explicit "when to escalate" triggers.
    • "What's Already Production-Ready in djust" — anti-recommendation list (Redis state, channels_redis, sync_to_async, transaction.on_commit, Origin check, HSTS) so users don't re-evaluate canonical patterns on every deployment.
    • Extended Gunicorn+Uvicorn workers section with concrete production CMD + flag rationale (-w sizing, --timeout 120, --keep-alive 5). Cloud-agnostic where possible; AWS as canonical example with PgBouncer / GCS / Cloudflare R2 noted in parallel.

Developer Experience

  • Pipeline-template canon: Stage 7 self-applicability check for canon PRs (#1248). New optional checklist item in .pipeline-templates/{feature,bugfix}-state.json Stage 7 fires when a PR adds new mandatory rules. Asks: (a) does the new rule false-positive on this PR's own diff? (b) would the new rule have caught the originating bug at the stage it adds? Both must be explicitly answered. v0.9.2-2 retro Action Tracker #206.
  • Pipeline-template canon: Stage 5/9/10 bundling check (#1251). New mandatory checklist item runs git diff --cached --stat immediately before git commit and verifies the staged line counts match the planned scope. Catches the failure mode where git add <file> silently bundles pre-existing uncommitted modifications (the pattern that hit pipeline-skill commit bf1a67f, silently bundling 130 unintended lines). v0.9.2-2 retro Action Tracker #209.
  • Audit script: extract retro-marker regex to shared constants module (#1249). Created scripts/lib/retro_markers.py with RETRO_MARKER_REGEX. The audit script (scripts/audit-pipeline-bypass.py) now imports the canonical constant rather than embedding the literal. Stage 14 subagent_prompt text in both pipeline templates references the script-canonical file rather than re-defining the regex. Single source of truth across consumers. v0.9.2-2 retro Action Tracker #207. 4 unit tests at scripts/lib/test_retro_markers.py.
  • Audit script: scan direct-to-main commits + Audit-bypass-reason: trailer support (#1250). The retro-gate audit GHA previously scanned merged PRs only; direct commits to main bypassed it (e.g., the v0.9.2-2 milestone-open commit 18e5b117). The audit now also lists direct-to-main commits since the lookback window, filters out PR-squash commits via (#NNN) subject suffix, and honors an Audit-bypass-reason: <text> commit-message trailer for legitimate exemptions (e.g., docs-only ROADMAP updates per the pipeline-drain skill). v0.9.2-2 retro Action Tracker #208.

Fixed

  • VDOM: mixed keyed/unkeyed children diff round-trip correctness (#1260). Surfaced by proptest during v0.9.2rc1 pre-flight. The LIS optimization in diff_keyed_children (crates/djust_vdom/src/diff.rs) skipped emitting MoveChild patches for keyed children whose trivial-length-1 LIS made them appear "in place" — relying on other patches' implicit position shifts to land them at the correct absolute index. This works for fully-keyed sibling lists (where all moves coordinate via absolute indices) but breaks when unkeyed siblings are interleaved (their patches use positions relative to other unkeyed nodes only). The keyed child ended up stranded at an arbitrary index after all patches applied. Fix detects has_unkeyed_siblings upfront; in the mixed case, falls back to "always emit MoveChild when old_idx != new_idx" instead of the LIS-implicit-position optimization. The fully-keyed path is unchanged. Audit weakness #5/#6 (rated 🟡 with warnings only) upgraded to 🟠 by this fuzz finding; this is the actual fix. 4 deterministic regression tests in crates/djust_vdom/tests/test_mixed_keyed_unkeyed_reorder_1260.rs
    • permanent proptest seed in fuzz_test.proptest-regressions.

[0.9.2rc1] - 2026-05-01

First release candidate for 0.9.2. Bundles three drain buckets shipped after 0.9.1 (2026-04-30): 0.9.2-1 (SSE transport DRY refactor — 5 issues, headlined by #1237), 0.9.2-2 (pipeline-template canon batch — 3 issues), 0.9.2-3 (VDOM correctness hardening Phase 1 — 5 issues). Plus the v0.9.2-3 audit doc (docs/vdom/AUDIT-2026-04-30.md). 13 issues closed across 5 PRs (#1238, #1239, #1241, #1242, #1246, #1247, #1257, #1258); 1 known issue surfaced during RC pre-flight (#1260 — fuzz-test mixed-keyed/unkeyed diff round-trip; deferred to v0.9.2-4 before stable).

Fixed

  • VDOM: stale cached_html for dj-update="ignore" subtrees (#1252). splice_ignore_subtrees (crates/djust_vdom/src/lib.rs) used to copy the old node's cached_html into the new node, which meant a conditional re-render that wraps an ignored subtree would keep serving the OLD cached HTML on subsequent diffs. The cache is now cleared (= None) during splice; cache_ignore_subtree_html recomputes lazily on the next render. 4 regression tests in crates/djust_vdom/tests/test_ignore_subtree_invalidation_1252.rs.

  • VDOM: dj-id template-injection defense-in-depth (#1253). The Rust parser now validates user-supplied dj-id attribute values against base62 (^[0-9a-zA-Z]+$) before the server-side ID generator overwrites them. Malformed values (whitespace, special chars, Unicode tricks) are dropped with a debug-level parser_trace! warning. The server-generated ID always wins; this fix tightens the pre-overwrite read path so any error/log surface that touches the prior value sees a sanitized form. 4 regression tests in crates/djust_vdom/tests/test_dj_id_validation_1253.rs.

  • VDOM: duplicate dj-key and mixed-keyed-unkeyed warnings now fire at tracing::warn! (#1254). Both warnings in crates/djust_vdom/src/diff.rs previously used vdom_trace!() (gated behind DJUST_VDOM_TRACE=1), so developers in production had no visibility into silent VDOM correctness issues. The mixed-keyed warning now fires with stable error code DJE-050; the duplicate-key warning with DJE-051. The previously-cited https://djust.org/errors/DJE-050 URL — which didn't exist — has been removed. Structured logging (key passed via {} placeholder) ensures the warnings are not log-injection vulnerable. 4 regression tests in crates/djust_vdom/tests/test_diff_warnings_1254.rs.

  • VDOM JS: Web Components and custom elements no longer silently replaced with <span> (#1255). The patcher's element-creation whitelist in python/djust/static/djust/src/12-vdom-patch.js was hardcoded to ALLOWED_HTML_TAGS + SVG_TAGS, rejecting Web Components (<my-component>, <sl-button>, <model-viewer>, etc.) and replacing them with a fallback <span>. The patcher now accepts any tag matching the HTML spec's custom-element rule (tag.includes('-')) and exposes a window.djustAllowedTags runtime-configurable hook for same-origin allowlist extensions. <script> and <iframe> remain blocked unchanged (<script> is not in the allowlist and lacks a hyphen; <iframe> is unaffected by this change since it's already in the existing ALLOWED_HTML_TAGS whitelist for legitimate use). 7 regression tests in tests/js/vdom_web_components_1255.test.js.

  • VDOM: extended SVG attribute camelCase normalization (#1256). The Rust parser's normalize_svg_attribute() table in crates/djust_vdom/src/parser.rs was missing modern SVG attributes (filter primitives, animation timing, gradient transforms, font-face metrics). Browsers' setAttributeNS is case-sensitive; without normalization, the unknown camelCase attrs were silently ignored, producing visually-broken SVG. 11 new attrs added; 11 regression tests in crates/djust_vdom/tests/test_svg_attr_normalization_1256.rs

    • 10 new cases on the existing in-module test.

Added

  • Transport-agnostic ViewRuntime shared between WebSocket and SSE (#1237). New python/djust/runtime.py module factors out view-lifecycle dispatch (dispatch_mount, dispatch_event, dispatch_url_change) so both transports share one code path for these message types. WebSocket's handle_url_change is now a thin shim over ViewRuntime.dispatch_url_change; SSE's new POST /djust/sse/<session_id>/message/ endpoint dispatches identically. First slice of a multi-PR migration that will progressively move the remaining WS handlers (handle_event, handle_mount, handle_mount_batch) onto the shared runtime. Architecture decision documented in ADR-016.
  • LiveViewSSE.sendMessage(data) — parity with LiveViewWebSocket (#1237). Existing liveViewWS.sendMessage(...) call sites in 18-navigation.js, 02-response-handler.js, 13-lazy-hydration.js, and 15-uploads.js now work transparently when the SSE transport is active — no callsite-by-callsite branching. The existing sendEvent API is preserved (delegates to sendMessage). The legacy POST /djust/sse/<sid>/event/ endpoint stays as a back-compat alias.

Fixed

  • SSE: URL kwargs resolved from the mount-frame URL, not the SSE endpoint path (#1237). Previously a view like path("items/<int:pk>/", ItemView.as_view()) mounted with empty kwargs over SSE because _sse_mount_view resolved against request.path (the SSE endpoint URL /djust/sse/<uuid>/, not the page). The client now sends a WebSocket-shaped mount frame containing url: window.location.pathname, and the server resolves kwargs against that URL — matching the WebSocket transport exactly. The HTTP Referer header is deliberately not used for this; see docs/sse-transport.md#why-not-the-referer-header for why.
  • SSE: LiveView.handle_params() is now invoked after mount and on url_change (#1237). Phoenix-parity contract: handle_params(params, uri) fires once after mount() and on every subsequent URL change. Previously SSE never called it, causing views that read URL state in handle_params (active tab, sort, page) to keep mount-time defaults regardless of query string.
  • SSE: liveViewWS.sendMessage({type: 'url_change', ...}) no longer TypeErrors (#1237). _executePatch() in 18-navigation.js calls sendMessage for dj-patch URL updates; under SSE this previously crashed because LiveViewSSE had no sendMessage method. Now both transports expose the same outbound API. Eight other JS call sites (popstate, lazy-hydration, response-handler, uploads, navigation) are also unblocked.
  • Service Worker reconnection bridge no longer needlessly buffers SSE payloads (#1237). 33-sw-registration.js patches sendMessage to buffer payloads when the WebSocket is closed. With SSE now also exposing sendMessage, the patch was unconditionally applying — and because ws.ws is undefined on LiveViewSSE, every SSE payload was treated as "socket closed" and forwarded to the SW. The patch now short-circuits on LiveViewSSE instances since SSE uses fetch() directly and doesn't need the WS reconnection buffer.
  • ViewRuntime.dispatch_mount rejects use_actors=True views with a structured error envelope over SSE (#1240). Closes plan-fidelity gap from #1237 — actor-based state management requires the channel-layer code in websocket.py which the runtime path doesn't traverse. Previously a use_actors=True mount over SSE would partially succeed and fail downstream with an opaque AttributeError. Now dispatch_mount short-circuits with a clear "use_actors is not supported over SSE; mount over WebSocket instead" envelope. ADR-016 §Implementation notes promised this guard; PR #1239 deferred it to this follow-up.

Developer Experience

  • Pipeline-bypass CI check — daily retro-gate audit (#1234). New scheduled GHA .github/workflows/retro-gate-audit.yml runs scripts/audit-pipeline-bypass.py daily at 13:00 UTC against the most recent 50 merged PRs and surfaces any PR missing retro markers as workflow annotations. Part 2 of #1212 (part 1 was the audit script shipped in PR #1229). Manual workflow_dispatch trigger included for ad-hoc audits.
  • Isolated cargo-test target for filter_registry::tests (#1235). The hot-path short-circuit tests for the ANY_CUSTOM_FILTERS_REGISTERED AtomicBool now live at crates/djust_templates/tests/test_filter_registry_isolated.rs (an integration-test binary). Cargo runs each integration-test file in its own process, so the process-global flag starts clean for every run — the previous OnceLock workaround that gated the in-module test on whether a prior test had already registered a filter is no longer needed. Carryover from #1180 item 4.
  • VDOM engine audit and v0.9.2-3 milestone (docs/vdom/AUDIT-2026-04-30.md). Synthesizes architecture map, bug archaeology (14 historical bugs across 7 themes), 10 ranked current-code weaknesses (3 🔴 / 7 🟡), test gaps, and a 4-phase improvement roadmap. Phase 1 (5 quick wins, #1252-#1256) opens as the v0.9.2-3 drain bucket; Phase 2 (correctness hardening) and Phase 3 (architectural — text-node djust_ids, unified focus state- machine) are deferred to later milestones.
  • Pipeline-template canon — Stage 4 + Stage 7 additions (#1243 + #1244). Two mandatory checklist items added symmetrically to .pipeline-templates/{feature,bugfix}-state.json:
    • Stage 4 VERIFY LITERAL API CONTRACTS — for every literal API call in the plan (function names, kwargs, return shapes), grep for the existing convention before locking. Pattern from #1240/#1242 where the plan said type="mount_error" but convention was error_type=.
    • Stage 7 WORKFLOW-HEADER CROSS-REF — when changed files include .github/workflows/*.yml or any file with a runtime-behavior docstring, list every behavioural claim and verify each against actual step semantics. Pattern from #1241 where the workflow's header said "annotations not red runs" but pipefail made every flagged run red.
  • Pipeline-run Stage 14 retro-post — Write tool + gh --body-file (#1245). Updates .pipeline-templates/{feature,bugfix}-state.json Stage 14 subagent_prompt to use Claude's Write tool to create pr/feedback/retro-<N>.md and gh pr comment <N> --body-file <path> to post — replacing the previous cat > file <<EOF + --body "$(cat file)" pattern that silently failed under zsh set -o noclobber (a common .zshrc safety guard). All 3 v0.9.2-1 implementation PRs (#1239, #1241, #1242) hit this and had their retros backfilled during the milestone retro audit; the new pattern is structural (sidesteps any shell-init quirk, not just noclobber) rather than a per-quirk patch.
  • Release-workflow dep-bump label gate (#1236). New GHA .github/workflows/check-release-workflow-deps.yml runs on PRs modifying release-critical workflow files (release.yml, publish.yml, release-drafter.yml, pre-release-security-audit.yml) and fails unless the PR carries the release-workflow-reviewed label, forcing explicit human risk-review before merge. Triggered by PR #1233 (action-gh-release v2 → v3) landing in the same window as the v0.9.1 cut. The release-workflow-reviewed label was added to the repo alongside this workflow.

[0.9.1] - 2026-04-30

Polish release on top of 0.9.0. Five drain buckets shipped between the 0.9.0 GA bump and this tag (0.9.1-1 through 0.9.1-5 under the new SemVer-pre-release-suffix milestone naming convention adopted 2026-04-30; equivalent to historical v0.9.1/v0.9.2/v0.9.3/v0.9.4/v0.9.5 drain buckets under the old naming). Headlined by a real-bug VDOM fix (#1205), a broadcast-recovery fix (#1202), the Debug Panel UI (#1151), and a RichSelect ergonomics expansion (#1204).

Added

  • RichSelect variant support, trigger tinting, and onclick parity (#1204). Each option dict accepts an optional variant key that tints the row in the dropdown AND the trigger when that option is currently selected. Built-in variants align with Badge/Button/Tag/Alert vocabulary (info, success, warning, danger, muted, primary, secondary). New variant_map kwarg mirrors Badge.status() for value→variant mapping cases. Permissive variant-name regex (^[a-z0-9][a-z0-9-]{0,31}$) lets downstream projects ship custom variants by adding a matching .rich-select-option--variant-<name> CSS rule. Trigger now emits the open/close onclick handlers that {% rich_select %} template tag always emitted, eliminating the monkey-patch-rendered-HTML workaround programmatic consumers used to need.

  • LiveViewTestClient.render_with_patches() — VDOM-diff accessor for tests (#1208). New public method on djust.testing.LiveViewTestClient that wraps view_instance.render_with_diff() and returns (html, patches_list, version) with the JSON patches parsed into a Python list. Empty list when no patches were produced. Reusable for any test that needs to assert on VDOM-diff invariants (e.g. "this noop event must produce zero patches"). First user is the strengthened test_normalize_idempotent_on_already_serialized regression test in tests/unit/test_list_model_diff_1205.py, which now locks the #1206 normalize-pass idempotency contract via an explicit patches == [] assertion instead of the prior weaker "no exception" check.

Fixed

  • JIT serializer silently degrades when context value is list[Model] (#1205, expanded by #1207). When a view's get_context_data override sets ctx["tasks"] = list(qs) after calling super().get_context_data(), the JIT auto-serialization pipeline runs inside super() and never sees the user-added value. The raw list[Model] then flows through _sync_state_to_rust, where change-detection compares list elements via Python == — which delegates to Model.__eq__ (pk-only). In-place field mutations (is_active toggle, completed flip) don't change pk, so the comparison returns equal, the key is never added to the diff-context, Rust never receives the new state, and the rendered HTML is byte-identical on every event despite confirmed DB writes. Symptom from the issue reporter: patch_count: 0 on every event, _debug.variables.tasks.value shows only __str__ strings.

    Initial fix (#1206): _sync_state_to_rust (in python/djust/mixins/rust_bridge.py) now runs a defensive normalize pass over full_context immediately after fetching it, converting any homogeneous list[Model] / Model / QuerySet value to dicts via normalize_django_value. After normalization, change-detection compares list[dict] != list[dict] element-wise via dict.__eq__ (structural), correctly catching field mutations. Idempotent on already-serialized values. Also removed dead _lazy_serialize_context method from python/djust/mixins/jit.py (zero call sites — was misleadingly cited as the bug location in the issue).

    Shape coverage expansion (#1207): the initial fix only handled homogeneous list[Model]; PR-review surfaced two more shapes that escape change-detection — heterogeneous [dict, Model] (Model not first; is_model_list checked only value[0]) and nested list[list[Model]] (grouped tasks). Refactored the inline normalize loop into a recursive _normalize_db_values helper that scans the full list for any-position Model and recurses into nested lists with bounded depth (_NORMALIZE_DEPTH_LIMIT = 3). 9 regression cases in tests/unit/test_list_model_diff_1205.py lock down all shape variants: homogeneous list[Model], single Model, raw QuerySet, heterogeneous [dict, Model], nested list[list[Model]], idempotency, empty list, and mixed-type list.

  • Broadcast renders now refresh _recovery_html (#1202). server_push in python/djust/websocket.py was sending broadcast patches without updating self._recovery_html / self._recovery_version (the user-initiated event path did this; broadcasts were overlooked). Consequence: when a subsequent applyPatches on the client failed (the well-known {% if %}-shifts-DOM case), the client sent request_html expecting fresh HTML, but handle_request_html read a stale or None-valued _recovery_html and returned recoverable: false. The client then triggered a full-page reload. Sessions that only received broadcasts after mount (admin dashboards, real-time apps with Celery pushes) never populated _recovery_html via the normal path. PR #1203 mirrors the handle_event recovery-state-store at websocket.py:3271: before sending broadcast patches, server_push now stores the render output as _recovery_html / _recovery_version. Two regression cases in tests/unit/test_server_push.py lock the store-after-broadcast invariant + the no-patches branch.

Process & tooling (internal)

The v0.9.1 arc shipped a substantial body of internal-tooling work that doesn't change user-facing API but improves contributor and maintainer ergonomics. Highlights for the audit trail (no migration needed):

  • Pre-push lints: scripts/check-no-dead-private-methods.py (#1209), scripts/check-no-comma-list-closes.py (#1227), scripts/audit-pipeline-bypass.py (#1212).
  • Pipeline-template Stage 4 reproducer-first mandatory item (#1210), Stage 11 reviewer-prompt budget guidelines (#1211), and the two-commit shape canonicalization + 3-clean-runs verification (Action Tracker #181/#182) as structural gates.
  • CodeQL sanitizer model for djust.security.log_sanitizer.sanitize_for_log (#1214) — closes the FP class PR #1201's 8 dismissals worked around.
  • CLAUDE.md "Bug-report triage" section citing PR #1206 as the canonical case study for issue-reporter-analysis-not-equal-root-cause discipline (#1213).
  • 20+ retro patterns canonicalized across CLAUDE.md sections for v0.6.x–v0.8.x retro arcs (#1226), v0.9.4 retro arc (#1225).
  • Pre-commit .pxd exclude prevents binary archive corruption (#1215).
  • Hot-reload auto-enable via DjustConfig.ready() in DEBUG mode (#1190).
  • Debug Panel UI for time-travel + forward-replay (#1151 / PR #1194, on top of v0.9.0's wire-protocol foundation).

[0.9.0] - 2026-04-29

The "Time Travel" release — the biggest release since 0.3.0, two years of work compressed into the v0.7 → v0.9 arc. Last release before the 1.0 testing arc.

The detailed per-rc breakdown is preserved in the [0.9.0rc1] through [0.9.0rc5] sections below; this is the consolidated GA summary plus post-rc5 additions.

Added

  • Time-Travel Debugging — per-component scrubber, forward-replay, branched timelines. Redux DevTools-class debugging for the server: every event captured, every component scrubbable, every counterfactual replay-able. Per-component time-travel (time_travel_component_jump) restores a single component's state without touching parent or siblings. Forward-replay (replay_event with optional override_params) re-runs a recorded event; if the cursor is not at the buffer tip OR override params are present, the framework allocates a fresh branch_id (branch-N) so the user sees exactly when they've forked the timeline. CSP-strict debug panel UI ships with branch indicator, replay buttons on every history row, and component expand-toggles. Closes #1041, #1042, #1151. Files: python/djust/time_travel.py, python/djust/websocket.py, python/djust/static/djust/src/debug/09a-tab-time-travel.js.

  • Server Actions — React 19 parity, Django-native. The @action decorator exposes a pending/error/result triple via self._action_state[name] to templates. No more boilerplate self.creating = True; try: ...; finally:. The dispatch pipeline catches errors and exposes them structurally. Mirrors React 19 useActionState shape closely enough that React refugees can port mental models without translation.

  • Async Streams — token-by-token UI. Three primitives (stream_to, stream_append, stream_prune) plus a StreamingMixin. Streaming infinite scroll, real-time feeds, and especially LLM output now have first-class support. Phoenix LiveView 1.0 parity, with Django ORM and the Django template language.

  • View Transitions API integration (Phase 2). djust now wraps every patch in a CSS View Transition where supported (Chrome / Edge); CSS escape hatches (view-transition-name, ::view-transition-old/new) work out of the box. Falls through to instant DOM updates on Firefox/Safari.

  • Sticky LiveViews + {% live_render %} auto-detect. Embedded LiveViews survive live_redirect navigation: WebSocket stays open, state preserved, in-flight start_async() tasks keep running. Auto-detect pass scans new layouts for matching [dj-sticky-slot] elements and preserves children that map.

  • HVR auto-enabled in DEBUG (zero-config hot reload). djust's own DjustConfig.ready() auto-calls enable_hot_reload() whenever DEBUG=True and watchdog is installed. Existing per-consumer calls keep working unchanged. Opt-out via LIVEVIEW_CONFIG['hot_reload_auto_enable']: False. Drop your watchfiles / --reload wrappers — HVR preserves view state, scroll position, and form input across edits.

  • Async render path (streaming_render = True) + lazy slots ({% lazy %}). Views with multiple slow data sources opt in to a fully-async render path; slots resolve via asyncio.as_completed (out of template order, fastest-first). Sync rendering remains the default.

  • Rust template engine {% live_render %} parity (#1145). The Rust engine now ships a registered handler for {% live_render %}. lazy=True users on RustLiveView no longer hit "no handler registered" errors; behaviour is byte-for-byte identical on Rust and Python paths.

  • {% data_table %} row-level navigation: a11y, keyboard, CSP-strict (#1111). Row-clickable rows render role="button", tabindex="0", respond to Enter/Space, short-circuit nested controls via capture-phase. Inline onclick replaced with external module so script-src 'self' works out of the box. Defense-in-depth regex validates data-href against javascript: / data: URIs.

  • Time-travel wire-protocol additive fields. time_travel_state ack frame and time_travel_event push frame both carry branch_id and related metadata; old clients ignore unknown keys (no flag day, no migration script).

  • Dedicated documentation site at docs.djust.org. Extracted from djust.org/docs/ into a standalone Django site built with djust itself (dogfooding). Pulls markdown from this repository via a pinned git submodule, so docs always match a specific released version of the framework. Launch covers all 23 user-facing guides, 9 API reference pages (from docs/ai/), the full 20-page component catalog, the changelog with deep-linkable per-release anchors, and the migration guide — 60 pages total. Source: djust-org/docs.djust.org.

  • RichSelect — per-option variant support and variant_map convenience kwarg. Each option dict can carry a variant key (info/success/warning/danger/muted/primary/secondary) that tints the dropdown row AND the trigger when selected. The variant vocabulary matches Badge/Button/Tag/Alert. Status-picker convenience: variant_map={"NEW": "info", "DONE": "success", ...}. Variant names validated with a permissive regex; downstream projects add custom variants by shipping matching CSS. 7 CSS rule blocks, 18 new unit tests, no breaking changes.

Changed

  • CSP-strict defaults canonicalized for new client-side framework code (#1175). New framework features emitting HTML must default to: external static JS modules (no inline <script>), no inline event handlers, marker class + delegated listener pattern. Reference modules: data-table-row-click.js, 50-lazy-fill.js, 39-dj-track-static.js. Strict-CSP deployments are now a design constraint, not an opt-in.

  • Theming cookie namespace for per-project isolation on shared domains (#1158). Opt-in LIVEVIEW_CONFIG['theme']['cookie_namespace'] so multiple djust projects on localhost:80xx don't overwrite each other's theme preferences.

  • Dev-deps include markdown and nh3 (#1149). [components] extra runtime deps are now also pulled in via [project.optional-dependencies.dev] so uv sync --extra dev brings them in alongside the rest of the test toolchain.

Fixed

  • server_push recovery-state consistency (#1202). Push-driven sessions previously left _recovery_html/_recovery_version unset, so a client request_html after a failed VDOM patch on a broadcast returned recoverable=false and force-reloaded the page. server_push now mirrors the handle_event pattern of populating recovery state immediately before dispatching broadcast patches.

  • Programmatic RichSelect class now emits the open/close interaction handlers previously only rendered by the {% rich_select %} template tag. onclick / onkeydown (Enter + Space) toggle the dropdown; each option row closes the dropdown on click. Parity with the template-tag variant via the shared _rich_select_resolve_variant helper.

  • Theming cookie namespace polish (#1169). Empty namespaced cookies no longer fall back to legacy unprefixed; whitespace-only namespace values rejected; write-side honours the namespace.

  • {% data_table %} row navigation polish (#1171). Nested-control selector now includes <details>, <summary>, <option>. Test-hook namespace cleaned up. Server-side URL allowlist contract test added.

  • A075 system check: {% live_render sticky=True lazy=True %} collision (#1146). Promoted from tag-eval-time TemplateSyntaxError to startup-time warning. Verbatim regions skipped; suppressible per-project.

  • Async iterator drain in arender_chunks (#1153). Unawaited _wait_for_one coroutine warning fixed via explicit _drain_iterator after _cancel_pending.

  • Test-runtime hygiene (#1186, #1152). Cross-runtime dispatchEvent warnings (happy-dom + undici) and view-transitions teardown noise filtered via narrow onUnhandledError patterns. Three consecutive make test runs exit 0 post-fix.

Security

  • Code-scanning cleanup (4 fixes + 15 documented FP dismissals). 19 open CodeQL/Dependabot alerts addressed, including JS open-redirect defense-in-depth (src/03-websocket.js), postcss 8.5.9 → 8.5.10 (CVE-grade XSS via unescaped </style>), empty except logging in mixins/sticky.py, and a duplicate import asyncio in mixins/request.py.

  • CSP-nonce-aware activator for <dj-lazy-slot> fills (#1147). {% live_render lazy=True %} propagates request.csp_nonce onto both the <template> element and the inline <script> activator. Strict-CSP sites no longer silently fail to mount lazy children.

Migration

Zero breaking changes from v0.8.x. All v0.7.x and v0.8.x APIs work unchanged.

Recommended cleanup (optional):

# Old — still works, but now redundant in DEBUG=True
class MyAppConfig(AppConfig):
    def ready(self):
        from djust import enable_hot_reload
        enable_hot_reload()

# New — djust handles it for you
class MyAppConfig(AppConfig):
    pass

Full migration notes: MIGRATION.md.

Quality bar at GA

  • 4080+ Python tests, 1486+ JavaScript tests — all green
  • 0 open security alerts as of GA
  • CSP-strict everywhere
  • Wire-protocol back-compat: 0.9.0 servers send all new fields additively; 0.6.1+ clients ignore unknown keys

[0.9.0rc5] - 2026-04-28

Fixed

  • server_push now stores _recovery_html / _recovery_version after broadcast renders (#1202) — push-driven sessions previously left recovery state unset, so a client request_html after a failed VDOM patch (e.g. {% if %} shifting DOM structure on a broadcast) returned recoverable=false and force-reloaded the page. server_push now mirrors the handle_event pattern of populating _recovery_html / _recovery_version immediately before dispatching the broadcast patches. Added 3 regression cases in tests/unit/test_server_push.py (single-push, multi-push refresh, no-op-push leaves recovery state intact).

Security

  • Code-scanning cleanup batch (4 fixes + 15 false-positive dismissals) — 19 open CodeQL / Dependabot alerts addressed:
    • JS open-redirect defense-in-depth (src/03-websocket.js:519): the fallback window.location.href = nav.to path now validates the target is a same-origin absolute path. Rejects protocol-relative URLs (//evil.com), absolute URLs to other origins, and javascript: / data: schemes. Closes CodeQL #2195.
    • postcss bumped 8.5.9 → 8.5.10 in package-lock.json — transitive via vitest → vite. Closes Dependabot #90 (XSS via unescaped </style> in CSS stringify output, GHSA).
    • Empty except AttributeError: pass in mixins/sticky.py:210 now logs at DEBUG with a comment explaining the expected case (read-only proxy children that can't accept a request attr). Closes CodeQL #2194.
    • Duplicate import asyncio in mixins/request.py:322 removed — module already imports asyncio at line 5. Closes CodeQL #2267.
    • 15 false-positive dismissals with documented reasoning:
      • 8× py/log-injection (#2254, #2253, #2239, #2238, #2237, #2236, #2235, #2183) — log calls already pass user-controlled input through sanitize_for_log() (the analyzer doesn't recognize the sanitizer).
      • 2× py/cyclic-import (#2231, #2230) — intentional lazy late-imports to break circular deps.
      • 1× py/not-named-self (#2268) — as_view is a @classonlymethod; cls is correct.
      • 2× py/unused-global-variable (#2272, #2175) — both are referenced multiple times (_CUSTOM_FILTERS_BRIDGED x4, _GCS_CHUNK_MIN_SIZE x3).
      • 1× py/catch-base-exception (#2273) — diagnostic CI script that must catch SystemExit subclasses; documented via noqa: BLE001.
      • 1× js/useless-assignment (#2174) — minified bundle artifact, not source; the 52 source modules in static/djust/src/ are authoritative.

[0.9.0rc4] - 2026-04-28

Added

  • Debug Panel UI for time-travel — per-component scrubber, forward-replay button, branch indicator (PR-B for #1151) — the user-facing UI built on top of the wire-protocol shipped in PR-A (#1193). Closes #1151.

    • Branch indicator at the top of the Time Travel tab — distinct badge styling for main (blue) vs branched timelines (orange, branch-N from forward-replay). Tracks the active branch_id from every server frame (both ack and event push).
    • "X / max" event count in the header so the user can see when they're approaching the configured time_travel_max_events cap.
    • Forward-replay button (⏵ replay) on every history row. Clicking sends a forward_replay frame with from_index set to that row's index; the server allocates a new branch_id if the replay diverges (non-tip cursor or override_params present).
    • Per-component expand-toggle (▶ N comp) on rows whose snapshot includes a __components__ dict. Expanding reveals a sub-row for each component with its truncated state preview and ↶ comp / ↷ comp buttons that scrub a SINGLE component's state via time_travel_component_jump — leaves parent view + other components alone.
    • CSP-strict: zero inline event handlers, all interactivity via the existing delegated click handler on the panel root. Per CLAUDE.md canon #1175.
    • Replay-hint label appears in the header when forward_replay_enabled is true (cursor is not at the buffer tip).

    Files: python/djust/static/djust/src/debug/09a-tab-time-travel.js (rewrote from 156 LoC to 320 LoC), python/djust/static/djust/debug-panel.css (90 LoC of additive .tt-branch*/.tt-comp-*/.tt-forward-replay/ .tt-expand-toggle rules), regenerated bundles debug-panel.js / .min.js / .min.js.gz / .min.js.br via scripts/build-client.sh. 23 new vitest cases in tests/js/debug_panel_time_travel_ui.test.js covering: backwards- compat ack frames, branch badge selection, count formatting, replay hint, expand-toggle visibility, component sub-row rendering, click dispatch for component-jump and forward-replay, override-params passthrough, branch_id update from event push frames, AND end-to-end delegated-click integration (real DOM clicks through registerTimeTravelClickHandlers).

  • Time-travel wire-protocol exposure for branched timelines + per-component scrubbing (PR-A for #1151) — server-side surface that the v0.9.4 debug panel UI (PR-B, follow-up) consumes. The Python plumbing for per-component time-travel (#1041) and forward-replay through branched timelines (#1042) shipped in v0.9.0; this PR exposes the missing wire fields so the debug panel can drive both.

    • time_travel_state ack frame: 3 new additive fields — branch_id (defaults "main"; new branches allocated as branch-{N} on forward-replay from a non-tip cursor), forward_replay_enabled (true iff cursor is not at the tip — meaningful replay would produce a branch), max_events (the configured ring-buffer cap, so the UI can show "X / max"). Old clients ignore the new keys.
    • time_travel_event per-event push frame: 2 new additive fields — branch_id and a top-level components mirror of entry.state_after.__components__ so the UI doesn't have to dig into the nested entry.
    • New handler time_travel_component_jump: scrubs a SINGLE component's state without touching the parent view or other components. Mirrors the existing time_travel_jump validation and re-render path; backed by a new restore_component_snapshot() helper in python/djust/time_travel.py.
    • New handler forward_replay: replays a recorded event with optional override_params and allocates a fresh branch id when the cursor is not at the buffer tip. Backed by the existing replay_event() (#1042) plus a new next_branch_id() allocator.
    • Live-view init: 2 new instance fields on LiveView.__init___time_travel_branch_id (default "main") and _time_travel_branch_counter (default 0). Both are inert when the buffer isn't allocated; zero memory cost for views that don't opt in to time-travel.

    Files: python/djust/websocket.py (dispatch arms + 2 handlers + ack builder), python/djust/time_travel.py (restore_component_snapshot, next_branch_id), python/djust/live_view.py (branch fields). 12 new cases (8 integration + 4 unit) covering ack-frame shape, replay-enabled semantics, component-only restore isolation, branch-id allocation, defensive defaults, override-params-at-tip branching, branch-id no-leak on replay failure, and which="after" component restore. PR-B (the debug panel UI consuming these fields) is the next v0.9.4 PR.

Documentation

  • v0.9.4 process canon (closes #1185, closes #1143, closes #1144) — three retro patterns from the v0.9.x arc canonicalized so the next drain doesn't repeat the same mistakes:
    • #1185: docs/PULL_REQUEST_CHECKLIST.md Closing-Keywords rule expanded to call out the parenthesized form (closes #X, closes #Y) explicitly. PR #1176 used it in the title and silently failed to close both issues. The checklist now names the failure mode and recommends always using PR-body lines for closing keywords.
    • #1143: CLAUDE.md "Process canonicalizations from v0.9.0 retro arc" section added — Stage-4 first-principles grep before architecting. Lists 5 canonical grep targets (wire-protocol, state-snapshot, async dispatch, decorator composition, component lifecycle) so Plan stages cite file:line of the pattern being mirrored.
    • #1144: same section — branch-name verify reflex. Pre-commit one-liner that compares git symbolic-ref --short HEAD against the active state file's branch_name field, catching the silent "wrong-branch commit" failure observed twice in v0.9.0.

Fixed

  • v0.9.4 test-infra polish (closes #1188, closes #1189) — three small follow-ups bundled as one PR:
    • #1188 🟡 #1: narrowed vitest.config.js Pattern 2 filter to match only the diagnosed Closing rpc + onUserConsoleLog / onConsoleLog cause from PR #1187. Dropped the broader stack.includes('view-transitions') disjunct so future genuinely-different failure shapes in view-transitions.test.js can no longer be silently swallowed.
    • #1188 🟡 #2: added gc.collect() before the _wait_for_one-warning absence check in tests/integration/test_chunks_overlap.py::test_cancel_does_not_leak_wait_for_one_warning. The warning fires from CPython's coroutine GC, not explicit code; the prior test passed by accident of CPython's reference-counting timing. Forcing collection makes the assertion deterministic under PyPy / free-threaded / different GC modes.
    • #1189: bumped test_large_template wall-clock bound from 100ms → 500ms with a comment explaining the test is a regression bound, not a benchmark. The prior tight bound flaked on busy CI runners (5-10ms typical local; 100ms+ under py3.13 free-threaded parallel suite load). Real perf tracking lives in pytest-benchmark, not this assertion.

Changed

  • HVR auto-enabled in DEBUG (no AppConfig.ready() boilerplate required) — djust's own DjustConfig.ready() now auto-calls enable_hot_reload() whenever DEBUG=True and watchdog is installed. Existing per-consumer enable_hot_reload() calls keep working unchanged (idempotent via hot_reload_server.is_running()). Opt out via LIVEVIEW_CONFIG['hot_reload_auto_enable']: False for projects that orchestrate the file watcher externally. Test runs auto-skip via PYTEST_CURRENT_TEST so pytest sessions don't spawn a watchdog thread per test. Files: python/djust/apps.py (auto-enable call appended to ready()), python/djust/config.py (new hot_reload_auto_enable: True default), python/djust/__init__.py (docstring update). 6 new cases covering auto-fire, opt-out config, pytest-env skip, idempotency, exception isolation, and other-setup completion (new file python/djust/tests/test_auto_hot_reload.py). Drops the one-line enable_hot_reload() call from examples/demo_project/demo_app/apps.py. Closes the friction observed across downstream consumers (docs.djust.org, djust.org, djustlive) that were either rolling their own watchfiles process-restart wrappers or silently missing the integration step altogether — the framework's HVR is strictly better than process restart (preserves view state, scroll position, form input across edits) but the consumer-side integration step was easy to skip.

[0.9.0rc3] - 2026-04-28

Fixed

  • v0.9.3 test-infra cleanup — suppress unhandled errors in JS + Python test runtimes (closes #1186, closes #1152, closes #1153) — release-blocker for v0.9.0rc3. Three test-runtime warnings/errors that surfaced during local make test but never affected production behavior, all unblocking the canonical exit-0 gate:
    • #1186 (P1): happy-dom + undici WebSocket dispatchEvent cross-pollination — undici fires a Node-side Event that happy-dom's EventTarget.dispatchEvent runtime check rejects (the two runtimes don't share a Web-platform Event prototype). Filtered via a new onUnhandledError hook in vitest.config.js matching a narrow message + stack pattern. Anything outside the pattern still re-throws.
    • #1152 (P2): view-transitions.test.js non-deterministic teardown EnvironmentTeardownError: Closing rpc while "onUserConsoleLog" was pending. Stubs already yielded a microtask per CLAUDE.md retro #1113, so the diagnosis was RPC-timing teardown noise, not a stub regression. Filtered via the same onUnhandledError hook.
    • #1153 (P2): real lifecycle bug in python/djust/mixins/template.py arender_chunks, not warning suppression. task.cancel() only signals cancellation — it doesn't unblock done.get() inside asyncio.as_completed's internal _wait_for_one. When arender_chunks returned mid-loop on emitter.cancelled, the for-protocol's already-pulled coroutine plus any further iterator-yielded coroutines were GC'd unawaited and Python emitted RuntimeWarning: coroutine '_wait_for_one' was never awaited. Fix: explicit _drain_iterator(as_completed_iter) after _cancel_pending() so the iterator's queue empties cleanly. Regression test test_cancel_does_not_leak_wait_for_one_warning in tests/integration/test_chunks_overlap.py asserts no _wait_for_one warnings via warnings.catch_warnings (1 new case).
    • Three consecutive make test runs exit 0 post-fix (was non-deterministic 1-3 unhandled errors out of 1463 passing JS tests + 4047 passing Python tests).
  • {% data_table %} row navigation polish — 3 sub-items from PR #1170 Stage 11 review (closes #1171) — final v0.9.2 drain item; tightens the row-navigation client module that shipped in #1170:
    • (a) Nested-control selector — add <details>/<summary>/<option> (R3). NESTED_CONTROL_SELECTOR was 6 tags (a, button, input, label, select, textarea); missed three common interactive elements. Disclosure widgets (<details>) and <select> children (<option>) now suppress row navigation when the user toggles or selects them. Pure additive selector change, no behaviour change for existing markup.
    • (b) Test-hook namespace refactor — drop window.__djustRowClickNavigate (R4). Production code now dispatches through window.djustDataTableRowClick.navigate, which is also the property tests stub via direct assignment (vi.fn). The underscored magic global is gone — cleaner contract; the namespace was already exported for bindRow / initAll in #1170.
    • (c) Server-side contract test for URL allowlist (R5). New tests/unit/test_data_table_url_allowlist_1171.py parametrizes 6 URL shapes (3 allowed, 3 hostile — //evil.com, javascript:..., data:...) and locks in the "render-doesn't-crash, wiring-is-stable" contract that the JS guard depends on. The actual open-redirect defense remains the regex in data-table-row-click.js; this Python test documents the server-side half of the boundary.
    • Test count delta: tests/js/data_table_row_click.test.js 14 → 17 (+3); new test_data_table_url_allowlist_1171.py 7 cases.

Changed

  • v0.9.2 hygiene group — Redis perf docstring softened, replay-rejection caplog assertions, descriptor-pattern auto-promotion gap documented, dev-env import regression guard (closes #1160, closes #1165) — Stage 11 follow-ups from the v0.9.1 retro arc, batched as a single chore PR:
    • #1160: rewrite test_redis_serialization_performance docstring in tests/unit/test_state_backend.py to match what the 100ms bound actually catches (catastrophic ~10× regressions, e.g. accidental JSON/pickle round-trip), not gradual perf drift. Points to pytest-benchmark-style median-based assertions for SLA-grade perf checks.
    • #1165 (a): extend TestReplayHandlerValidation rejection-path tests in tests/unit/test_time_travel.py to assert via caplog that the logger.warning(...) record fires with the expected message ("refused unregistered method" / "refused dunder/private event_name"). Side-effect-only assertions previously stayed green if the warning silently regressed to a no-op.
    • #1165 (b): document the descriptor-pattern auto-promotion gap in the LiveComponent docstring (python/djust/components/base.py) and in docs/website/guides/components.md. The framework's _assign_component_ids walker only inspects instance-level attrs, so descriptor components must be appended to self._components in mount() until auto-promotion ships. Time-travel snapshots and other walkers silently miss them otherwise.
    • #1165 (c): add scripts/check-dev-env-imports.py and a paired pytest module (tests/unit/test_dev_env_imports.py, 2 new parametrized cases) that hard-fail (not skip) if djust.components.components or its .markdown submodule cannot import. Locks in the #1149 fix where missing markdown/nh3 caused opaque pytest collection failures. Script is standalone for now; a follow-up PR can wire it into pre-commit / Makefile.
  • CSP-strict defaults canonicalized for new client-side framework code (closes #1175) — adds explicit guidance in CLAUDE.md, docs/PULL_REQUEST_CHECKLIST.md, and docs/guides/security.md that any new framework feature emitting HTML must default to: external static JS modules (no inline <script> blocks), no inline event handlers (no onclick=/onchange=/oninput=), auto-bind via marker class + delegated listener on document/root, CSP nonce propagation only when genuinely required (lazy-fill case from #1147 is the canonical exception). Reference-module shapes documented (PR #1170 data-table-row-click.js, PR #1138 50-lazy-fill.js, existing 39-dj-track-static.js). v1.0 readiness — positions strict-CSP deployments as a design constraint, not an opt-in.

Added

  • {% data_table %} row-level navigation: accessibility, keyboard, and CSP-strict layer (closes #1111) — layers v0.9.1 quality additions onto the prior #1111 row-navigation scaffolding (which shipped row_click_event / row_url template-tag args, mixin defaults, and structural wiring). What's added:

    • Accessibility: every row-clickable <tr> now renders role="button", tabindex="0", and cursor:pointer. Screen readers announce the row as a button; keyboard users get focus.
    • Keyboard activation: Enter and Space on a focused row fire the configured action. Guarded by document.activeElement === tr so Space inside a nested input doesn't hijack the keystroke.
    • Nested-control guard: clicks inside <a>, <button>, <input>, <label>, <select>, <textarea> are short-circuited via capture-phase stopImmediatePropagation, so the row-level action never fires for those clicks. This is the integration point with selectable=True (per-row checkbox) and the cell-level link column (#1110).
    • CSP-strict friendly: the row_url path's previous inline onclick="window.location=this.dataset.href" is replaced by a new component JS module (python/djust/components/static/djust_components/data-table-row-click.js). No inline event handlers, no nonce plumbing — works under script-src 'self' out of the box.
    • Defense-in-depth: data-href values are regex-validated against /^(https?:|\/|\.)/ before window.location.assign, so a hostile javascript: URI cannot execute even if it sneaks into the row dict.
    • Multi-line template comments fixed: the pre-existing {# ... #} row-nav and link-column doc comments were rendering as literal text in output because Django's {# %} is single-line-only. Converted to {% comment %}...{% endcomment %}.

    New cases in TestRowClickAccessibility, TestRowClickableMarkerClass, TestRowClickAffordance, TestCSPInlineHandler, TestSelectableComposition, TestCSPNonce (tests/unit/test_data_table_row_navigation_1111.py, 14 Python cases) plus 11 JS cases in tests/js/data_table_row_click.test.js cover: role + tabindex presence, marker class on/off, no-inline- onclick (CSP), checkbox cell composition, click navigation, nested <a>/<input> guard, Enter/Space activation, activeElement guard, javascript: URI rejection, dj-click composition (capture-phase stop), and bindRow idempotence. One pre-existing structural test in python/tests/test_data_table_link_row_nav.py was rewritten to assert the new data-table-row-clickable marker class instead of the removed inline onclick.

  • Theming cookie namespace for per-project isolation on shared domains (closes #1158) — adds opt-in LIVEVIEW_CONFIG['theme']['cookie_namespace'] setting so multiple djust projects on localhost:80xx (or any shared domain) don't overwrite each other's theme preferences. Browsers scope cookies by domain only — not by port — so the four djust_theme* cookies bleed across projects without this. PR #1013 already shipped enable_client_override: False as a workaround, but that breaks sites with a user-facing theme switcher; this is the missing piece for those sites. When cookie_namespace="djust_org" is set, the cookies become djust_org_djust_theme, djust_org_djust_theme_preset, djust_org_djust_theme_pack, djust_org_djust_theme_layout. Read path tries namespaced first, falls back to unprefixed once on upgrade so users keep their existing theme. Write path (theme.js) reads window.__djust_theme_cookie_prefix injected by theme_head.html and writes only the namespaced name when set. When unset (default), the legacy unprefixed names are used — existing deployments unaffected. 8 new regression cases in tests/unit/test_theming_cookie_namespace_1158.py cover namespaced precedence, unprefixed fallback, default back-compat, two-namespace isolation, all four cookies honour the namespace, and the theme_head.html + theme.js write-side wiring.

  • Rust template engine {% live_render %} lazy=True parity (closes #1145) — the Rust template engine now ships a registered handler for {% live_render %}, closing the v0.9.0 PR-B (#1138) gap. Before this, production users on RustLiveView got a "no handler registered for tag: live_render" template error if they used lazy=True, forcing a fallback to the slower Django engine to use streaming. The Rust handler delegates to the existing Python implementation in djust.templatetags.live_tags.live_render, so behaviour is byte-for-byte identical on both paths — same <dj-lazy-slot> placeholder shape, same thunk-stash side effect on parent._lazy_thunks, same CSP nonce propagation, same sticky=True + lazy=True collision raise. The bridge required threading the raw Python sidecar (request, view) through to the custom-tag handler context: crates/djust_core exposes Context::raw_py_objects() for read access, and crates/djust_templates::registry adds call_handler_with_py_sidecar (a backward-compatible variant of call_handler — existing handlers ignore the extra Python objects in their dict). 8 parity regression cases in tests/unit/test_rust_live_render_lazy_1145.py cover lazy=True placeholder byte equivalence, lazy="visible" parity, thunk stash on the Rust path, CSP nonce parity, sticky+lazy collision, the inline-attribute template = "..." mode (the original failure surface from PR #1138 integration tests), and eager-mode regression-guard.

  • A075 system check: {% live_render sticky=True lazy=True %} collision (closes #1146) — promotes the existing tag-eval-time TemplateSyntaxError to a startup-time warning so the misuse surfaces during manage.py check instead of waiting for a request to render the offending template. Sticky preservation requires the slot to exist at mount-frame time so the WebSocket reattach can replaceWith the stashed subtree; lazy=True defers slot rendering until after the parent shell flushes — the stash target doesn't exist when reattach runs. The check skips {% verbatim %}...{% endverbatim %} regions so docs/marketing pages showing the anti-pattern as a literal example don't false-positive (re-uses the _strip_verbatim_blocks helper from the v0.7.3 #1004 fix). Silenceable per-project via DJUST_CONFIG = {"suppress_checks": ["A075"]}. 8 regression cases in TestA075StickyLazyCollision cover collision firing, sticky-only / lazy-only silence, verbatim suppression, real-call next to verbatim example, config disable knob, and string-truthy kwarg shapes.

Security

  • CSP-nonce-aware activator for <dj-lazy-slot> fills (closes #1147){% live_render lazy=True %} now propagates request.csp_nonce (the Django convention set by django-csp middleware) onto BOTH the <template id="djl-fill-X"> element AND the inline <script> activator that calls window.djust.lazyFill(...). Sites with strict CSP (script-src 'nonce-...', no 'unsafe-inline') previously had the activator silently rejected at parse time, and lazy children never mounted. The fix reads getattr(request, 'csp_nonce', None) via the existing djust.utils.get_csp_nonce helper — no additional configuration is required for any CSP middleware that follows the Django convention. When request.csp_nonce is absent or empty (the common case for sites without CSP middleware), no nonce attribute is emitted — backward-compatible for non-CSP deployments. The placeholder <dj-lazy-slot> also carries the nonce so client-side code can read it via getAttribute('nonce') if it ever needs to inject CSP-bound scripts under the same policy. 6 Python regression cases in tests/unit/test_lazy_render_csp.py
    • 3 JS cases in tests/js/lazy_fill_csp.test.js cover nonce propagation, backward compatibility (no nonce attr when csp_nonce is absent / empty / missing), and HTML-escaping defense-in-depth for hostile-middleware substitutes.

Changed

  • Dev-deps include markdown and nh3 (closes #1149) — both packages are runtime deps of the [components] extra (see python/djust/components/components/markdown.py) and the components subpackage's __init__.py eagerly imports them via from .markdown import Markdown. Tests that import djust.components.components (directly or transitively) failed collection in clean checkouts that ran only uv sync without the [components] extra. The bisect agent in PR #1159 hit this on a fresh clone. Added both to [project.optional-dependencies.dev] so a single uv sync --extra dev brings them in alongside the rest of the test toolchain. No behaviour change for runtime users — [components] already lists both as runtime deps.

Fixed

  • Theming cookie namespace polish — 4 sub-items (closes #1169) — Stage 11 follow-ups from PR #1168 (the original cookie-namespace work for #1158):

    • (a) Empty namespaced cookie no longer falls back to legacy. ThemeManager.get_state() previously evaluated the namespaced cookie via _read('<ns>_name') or None, so an empty-string value ("") silently fell through to the unprefixed legacy cookie — re-opening the cross-project bleed path #1158 closed. The read now distinguishes None (cookie not in jar) from "" (cookie set to empty), and only falls back in the former case.
    • (b) cookie_namespace validated at config-load. The value is interpolated directly into cookie names; whitespace, =, ;, and non-ASCII characters previously produced malformed Set-Cookie headers (browsers reject or split such cookies). _validate_cookie_namespace() now raises ImproperlyConfigured at startup for any value outside [A-Za-z0-9_-]+.
    • (c) JSDOM tests for the cookie WRITE side. The 8 #1158 Python tests only asserted on theme.js source-text patterns; new tests/js/theming_cookie_namespace_write.test.js loads the file in JSDOM, sets window.__djust_theme_cookie_prefix, fires setPack/setPreset/setLayout, and inspects document.cookie.
    • (d) Legacy-cookie cleanup on first namespaced write. When cookie_namespace is set, every theming-cookie write in theme.js now also emits Max-Age=0 for the unprefixed legacy name. Stale legacy cookies left over from before namespace was configured no longer sit in the jar forever and bleed back if the namespace is later removed. Cleanup is inert when no prefix is configured (back-compat).

    3 new regression cases in tests/unit/test_theming_cookie_namespace_1158.py (1 for sub-item (a), 2 for sub-item (b)) plus 7 new JS cases in tests/js/theming_cookie_namespace_write.test.js (4 for sub-item (c), 3 for sub-item (d)).

  • Tag-registry test isolation + sidecar bridge extension to block / assign tags (closes #1167) — two Stage 11 follow-ups from PR #1166 (which wired the raw-Python sidecar into Node::CustomTag):

    • Test isolation: tests/unit/test_tag_registry.py previously used per-class setup_registry fixtures that re-registered the Python built-in handlers on teardown but did NOT clear the global Rust TAG_HANDLERS registry first. Transient handlers from the file (notably BrokenHandler registered for the broken tag in test_handler_exception_returns_error) leaked into subsequent test files. test_assign_tag.py running after this file would see handler_exists("broken") == True; the parser dispatches handler_exists before assign_handler_exists so {% broken %} was routed to the leaked CustomTag handler and test_non_dict_return_is_empty_merge failed with the leaked handler's exception. Fix: replace the per-class fixtures with one function-scoped autouse fixture that clears all three Rust registries (tag / block-tag / assign-tag) before AND after every test, then re-registers the built-ins from djust.template_tags._registered_handlers. The file is now self-contained.
    • Sidecar parity: PR #1166's call_handler_with_py_sidecar only fired for Node::CustomTag. Block tags (Node::BlockCustomTag) and assign tags (Node::AssignTag) didn't receive the request / view sidecar, so a custom block or assign handler couldn't reach the parent view. Added call_block_handler_with_py_sidecar and call_assign_handler_with_py_sidecar mirroring the PR #1166 pattern; the existing variants are kept as back-compat shims that delegate with None. All five renderer call sites (1× block, 4× assign — single-node, sibling-aware, collecting, and partial-render paths) forward context.raw_py_objects().

    New cases in TestBlockTagSidecar and TestAssignTagSidecar (tests/unit/test_tag_sidecar_parity_1167.py, 6 Python cases) cover sidecar receipt of request and view per node type plus a back-compat regression per node type confirming legacy handlers that ignore the sidecar continue to work unchanged.

  • Custom filter bridge polish — 6 sub-items deferred from #1161 (closes #1162) — Stage 11 review of PR #1161 (which closed #1121 by adding the eager Rust filter registry) flagged six follow-ups. All are addressed in this PR:

    1. Hot-path Mutex perf: is_custom_filter_safe and apply_custom_filter short-circuit on a new ANY_CUSTOM_FILTERS_REGISTERED AtomicBool so projects with no custom filters pay only an atomic load on every variable expansion's filter_specs.iter().any(...) loop, never a Mutex acquire. Acquire/Release ordering pairs the load with the store in register_custom_filter.
    2. Hardcoded autoescape=True plumbing (correction, #1180): apply_custom_filter now accepts an autoescape: bool parameter that's set as a kwarg on the Python callable when the filter declares needs_autoescape=True. The earlier wording here was inaccurate — only apply_custom_filter was widened; the upstream chain (apply_filter_full in filters.rs and the renderer's three call sites at renderer.rs:287, 349, 1602) was NOT threaded through. Future {% autoescape %} block tracking will need to update ~4 sites to plumb the dynamic value end-to-end, not 1.
    3. Unknown-filter test tightened: assert RuntimeError type AND the canonical "Unknown filter:" message shape, not just pytest.raises(Exception) + substring on filter name only.
    4. Dropped unused custom_filter_exists: dead public Rust function with no callers in the workspace; PyO3 macros suppress the dead-code warning so it would have rotted silently.
    5. Fixture isolation comment: the scope="module" autouse fixture in tests/unit/test_rust_custom_filters_1121.py now carries an explicit comment that this file is not safe to run in parallel with other Rust-filter-registry-touching tests.
    6. Silent async filter handling: an async def custom filter previously stringified the unawaited coroutine ("<coroutine object ...>") into the rendered HTML with a "coroutine was never awaited" RuntimeWarning at GC. Now uses inspect.iscoroutine to detect and reject with a clear, actionable error and coro.close() to suppress the GC warning.

    New cases in TestNewBehavior_1162 (tests/unit/test_rust_custom_filters_1121.py, 2 Python cases) cover async-filter rejection (sub-item 6) and autoescape kwarg flow (sub-item 2). Two new Rust unit tests in filter_registry::tests cover the AtomicBool short-circuit pre-registration.

  • replay_event validates handler is @event_handler-decorated (closes #1148) — defense-in-depth strengthening of the v0.9.0 #1042 forward-replay path. The original guard rejected only dunder/private event_name (startswith("_")), which still admitted ANY public method on the view — helpers, inherited utilities, property getters — even though the dispatcher only ever invokes @event_handler-decorated methods. A hand-edited or malicious snapshot could replay e.g. view.delete_all_records() even when that method was never exposed to the dispatcher. The fix calls djust.decorators.is_event_handler(handler) after attribute resolution, mirroring the dispatcher's own acceptance criteria (see websocket.py ~ line 4389 server_push handler validation). Unregistered methods log a warning and return None instead of invoking. 3 regression cases in TestReplayHandlerValidation cover registered-handler success, unregistered-method rejection, and the existing dunder-rejection regression.

  • Rust template renderer rejects project-defined custom filters (closes #1121) — Django projects registering custom filters via @register.filter in their templatetags/ modules saw them work in the Python render path but fail under the Rust RustLiveView render path with RuntimeError: Template error: Unknown filter: <name>. The Rust engine's filter dispatch was a hardcoded match against Django's 57 built-in filter names with no fallback for project-level filters. The fix is a Python→Rust bridge mirroring the existing custom-tag-handler design (crates/djust_templates/ src/registry.rs):

    • New crates/djust_templates/src/filter_registry.rs holds a process-wide Mutex<HashMap<String, FilterEntry>> of project filter callables + per-filter metadata (is_safe, needs_autoescape).
    • The renderer's filter loop forwards an arg_was_quoted hint from the parser so the bridge can resolve bare-identifier args against the template context before calling Python — fixing the {{ my_dict|lookup:some_key }} shape from the issue body.
    • Both filter.is_safe and filter.needs_autoescape from the Django filter object are honoured: is_safe=True filters skip auto-escape; needs_autoescape=True filters receive autoescape=True as a kwarg.
    • python/djust/template_filters.py walks template.engines['django'].engine.template_libraries at the first LiveView render and bulk-registers every custom filter found. Built-in Django filter names are skipped (the Rust engine has native implementations of all 57). The bootstrap is idempotent — late-loaded apps' filters are picked up on subsequent renders.
    • Unknown filter names still raise the original Unknown filter: <name> error so typos and missing imports surface immediately. 10 regression cases in TestRustCustomFilters cover the lookup shape from the issue body, is_safe, needs_autoescape, quoted vs context- resolved args, plain-text auto-escape, and the full RustLiveView render path.
  • Test pollution: 6 flaky tests in full-suite pytest run (closes #1134) — bisected two independent polluters that surfaced after v0.9.0 PR-A (#1135) added the aget/ChunkEmitter async-render path and after PR #998 added the block_watchdog test fixture:

    • In-memory SQLite + Channels disconnect: 5 tests (test_websocket_origin_validation::TestConnectOriginValidation's 4 accepting-handshake cases + test_request_path::test_websocket_mount_counter) failed during communicator.disconnect() because Channels' consumer dispatch invokes aclose_old_connections(), which iterates Django's connection cache and calls close_if_unusable_or_obsolete()get_autocommit()ensure_connection(). SQLite ignores close() for in-memory DBs (data-loss prevention), so a prior django_db-marked test leaves the connection wrapper with .connection != None in the thread-local; pytest-django's blocker then fires inside the consumer's cleanup. Marked the affected tests @pytest.mark.django_db so they participate in pytest-django's connection management.
    • sys.modules["djust.checks"] rebind: the test_dev_server_watchdog_missing.py::test_check_hot_view_replacement_survives_without_watchdog test deleted djust.checks from sys.modules and re-imported, creating a new module object while test_static_security_checks.py had already done from djust.checks import check_configuration at collection time. Subsequent mock.patch("djust.checks._has_multiple_permission_groups", ...) targeted the new module while the old check_configuration kept resolving names against the old module's __dict__ — so the patch silently no-op'd and test_a020_fires_with_multiple_groups failed. Moved snapshot/restore of djust.checks and djust.dev_server into the block_watchdog fixture's setup/ teardown so the eviction is local to the test's lifetime.
    • Redis-serialization-performance 10ms wall bound: relaxed the bound from 10ms to 100ms — under heavy full-suite load (GC pauses, scheduling jitter) the ideal-conditions 10ms ceiling was producing false positives. 100ms still catches "we accidentally serialized via JSON/pickle round-trip" regressions without the timing flake.

[0.9.0rc2] - 2026-04-27

Changed

  • WizardMixin.as_live_field auto-picks dom_event by widget class (closes #1156) — previously the view-level wizard_input_event attribute applied uniformly to every widget the wizard rendered. An author setting wizard_input_event = "dj-input" to capture unblurred text edits (per #1095) unintentionally also stamped dj-input on radios, selects, and checkboxes — which was semantically wrong (there's no keystroke stream to fire on) and pre- #1155 incurred a 300ms debounce stall on every click.

    as_live_field now inspects the field's widget class (walking the widget's MRO so any subclass of an enumerated builtin inherits the default automatically) and picks:

    • dj-change for click-fired widgets — RadioSelect, CheckboxInput, CheckboxSelectMultiple, Select, plus every Django Select subclass (SelectMultiple, NullBooleanSelect) and any app's RadioSelect/Select subclass matched via MRO. They commit exactly one value per user interaction, no stream to batch.
    • wizard_input_event for text-stream widgets (TextInput, Textarea, NumberInput, EmailInput, etc.) — preserves the #1095 contract for authors who need unblurred-text capture.
    • Caller-passed dom_event="..." still wins — the widget-aware default is a default, not a mandate.

    Apps that had implemented their own as_live_field override to do exactly this mapping can delete the override.

    New _CLICK_FIRED_WIDGET_CLASSES ClassVar (frozenset of widget class names) lets apps with custom commit-style widgets extend the dispatch without overriding as_live_field itself:

    class MyWizard(WizardMixin, LiveView):
        _CLICK_FIRED_WIDGET_CLASSES = frozenset({
            *WizardMixin._CLICK_FIRED_WIDGET_CLASSES,
            "MyColorPickerWidget",
        })
    

    Files: python/djust/wizard.py (new _default_dom_event_for helper + _CLICK_FIRED_WIDGET_CLASSES ClassVar, ~20 LoC; updated wizard_input_event docstring to clarify text-only scope). 15 new cases in TestAsLiveFieldWidgetAwareDomEvent in tests/unit/test_wizard_mixin.py cover text/textarea/integer/email tracking wizard_input_event, radio/select/checkbox/ CheckboxSelectMultiple locked to dj-change, caller-passed dom_event overrides, ClassVar extension for custom widgets, and MRO walk catching SelectMultiple / NullBooleanSelect / app-defined RadioSelect subclasses.

Fixed

  • dj-input on click-fired widgets no longer incurs a 300ms debounce (closes #1154)DEFAULT_RATE_LIMITS in python/djust/static/djust/src/08-event-parsing.js was missing entries for radio, checkbox, select-one, and select-multiple. The input handler's fallback ({ type: 'debounce', ms: 300 }) kicked in for these widget types, so WizardMixin.wizard_input_event = "dj-input" — the class-wide setting recommended by #1095 — silently inserted 300ms of dead air between a radio click and the WS event being sent.

    Fix adds a new passthrough rate-limit type for click-fired widgets (they commit exactly one value per user interaction, no stream to batch) plus a branch in the input handler in 09-event-binding.js that skips the rate-limit wrapper when rateLimit.type === 'passthrough'. Text/textarea fields retain their 300ms debounce unchanged. The defensive 300ms fallback for unknown widget types is intact.

    Real-world measurement from a wizard with a Yes/No radio and wizard_input_event = "dj-input":

    click → WS sendtotal click → DOM
    Before1104 ms~1150 ms
    After1 ms~75 ms

    dj-debounce/dj-throttle explicit overrides on a radio still work — passthrough is the default, not a mandate. Files: python/djust/static/djust/src/08-event-parsing.js (4-line DEFAULT_RATE_LIMITS extension), python/djust/static/djust/src/09-event-binding.js (7-line passthrough branch + a one-line Object.assign({}, …) clone of the default before the override branches mutate it — without the clone, dj-debounce/dj-throttle on one element permanently flips the shared DEFAULT_RATE_LIMITS entry and pollutes every subsequently- bound element of the same type). 9 new cases in tests/js/dj-input-click-widgets.test.js lock in synchronous firing for radio/checkbox/select-one/select-multiple, continued debounce for text/textarea, that dj-debounce overrides still apply, and that an override on one radio does not leak into a sibling radio's wrapper (regression for the shared-state mutation).

[0.9.0rc1] - 2026-04-27

Added

  • Forward-replay through branched timeline (closes #1042, v0.9.0 P3) — Redux DevTools "swap action" parity. Time-travel previously only scrubbed BACK through linear history; replay_event(view, snapshot, override_params=None, record_replay=True) now replays a recorded event from its state_before baseline either deterministically (original params) or with caller-supplied override_params to fork a branched timeline.

    Builds on #1041's per-component capture: replay restores via restore_snapshot(view, snap, "before") which dispatches to view._components[id] instances. So a handler that reads self._components[id].value during replay sees the CAPTURED value, not the live one. The test test_replay_restores_component_state_before_invoking locks this in.

    Branches are scrubbable: record_replay=True (default) appends the replay's new snapshot to the buffer so the branched timeline is itself navigable. record_replay=False runs a "dry" replay — view is mutated for preview, buffer is unchanged.

    Handler-missing path: returns None and logs a warning (handler was renamed since the snapshot was captured). Handler-raises path: the new snapshot's error field is set and the snapshot is still returned so the debug panel can show "this branch errored at step N".

    Files: python/djust/time_travel.py (~85 LoC: replay_event function + __all__ extension). 7 new cases in TestReplayEvent in tests/unit/test_time_travel.py cover deterministic replay, branched timeline (override_params), buffer recording, dry replay, missing handler, handler exception, and component-state restoration during replay.

    v0.9.0 streaming + DevTools arc complete: PR-A foundation → PR-B lazy=True API → PR-C parallel render → #1041 component-level capture → #1042 forward-replay.

  • Component-level time-travel (closes #1041, v0.9.0 P3) — extends the v0.6.1 time-travel ring buffer to capture per-component public state alongside the parent LiveView's state. Multi-component pages can now scrub back through history with each component's state faithfully restored.

    Snapshot format: _capture_snapshot_state adds a reserved __components__ key holding a {component_id: {field: value}} nested dict. Components in self._components (registered by _assign_component_ids) each contribute their public state. The reserved key keeps component snapshots out of the parent's flat attr namespace and gives the time-travel debug panel a clean shape to render per-component scrubbers.

    Restoration: time_travel.restore_snapshot detects __components__ in the snapshot and dispatches each {component_id: state} entry to the matching component in view._components via safe_setattr. Components absent from the snapshot keep their current state — components are first-class instances, not parent-scoped attrs, so the ghost-attr cleanup model used for parent state doesn't apply.

    Files: python/djust/live_view.py (~60 LoC: _capture_components_snapshot helper + _capture_snapshot_state extension); python/djust/time_travel.py (~40 LoC: _COMPONENTS_SNAPSHOT_KEY constant + per-component restoration phase). 7 new cases in TestComponentLevelTimeTravel in tests/unit/test_time_travel.py cover capture-with-components, capture-without-components, private/callable filtering, restoration dispatch, unknown-component-id handling, absent-component preservation, and snapshot/live disconnection (mirrors the parent-state aliasing fix from PR #1023's Stage 11 review).

  • Parallel lazy render via asyncio.as_completed (v0.9.0 PR-C, closes #1043) — closes the v0.9.0 streaming arc. PR-B shipped sequential thunk invocation in arender_chunks Phase 5 (one thunk runs to completion before the next starts; total wall-clock time = sum of thunk durations). PR-C swaps the for-loop for asyncio.as_completed over the thunk-task set. All thunks start concurrently; chunks emerge in completion order rather than registration order. Total wall-clock time = max(thunk_durations).

    Client-side reconciliation is keyed by slot id (data-target on <template id="djl-fill-X">), so out-of-order chunk arrival is correct by construction — no client changes needed.

    Cancellation: when the emitter is cancelled mid-stream (client disconnect), all pending thunk tasks are cancelled via task.cancel(). Already-completed tasks whose results were not yet iterated are GC'd. Tasks already running through sync_to_async to a synchronous render function will complete (asyncio cancellation doesn't propagate into sync DB work) — the documented contract per ADR-015 §"Cancellation contract".

    Files: python/djust/mixins/template.py (~50 LoC swap from for-loop to asyncio.as_completed). 3 new wall-clock-sensitive tests in tests/integration/test_chunks_overlap.py:

    • Three thunks (100ms, 50ms, 25ms) registered in that order → chunks arrive in completion order (slot-c, slot-b, slot-a).
    • Three 50ms-each thunks → wall clock under 100ms (sequential baseline 150ms).
    • One thunk raises → others still emit their fills (no stall).

    Closes #1043. v0.9.0 streaming arc complete: PR-A (foundation) → PR-B (lazy=True user API + as_view dispatch) → PR-C (parallel render).

  • {% live_render lazy=True %} capability + as_view dispatch wiring (v0.9.0 PR-B, ADR-015) — ships the user-facing API on top of PR-A's async render foundation. Three forms: lazy=True (parent-flush trigger, default), lazy="visible" (IntersectionObserver-deferred), lazy=dict (full control — trigger, timeout_s, on_error, placeholder keys).

    At template-render time the tag emits a <dj-lazy-slot data-id="X" data-trigger="flush"> placeholder synchronously and registers a thunk on parent._lazy_thunks. RequestMixin.aget transfers the stash onto the ChunkEmitter after the sync render completes. Phase-5 of arender_chunks invokes thunks AFTER the body-close chunk, so </body></html> lands at the wire BEFORE any lazy fill — the browser sees a fully-painted page (with placeholder spinners) while lazy children render server-side.

    Wire format (post-</html> per ADR §"Wire format"):

    <template id="djl-fill-X" data-target="X" data-status="ok">
      <div dj-view data-djust-embedded="X">…rendered child…</div>
    </template>
    <script>window.djust.lazyFill('X')</script>
    

    The new python/djust/static/djust/src/50-lazy-fill.js module's window.djust.lazyFill(slotId) function scans for matching <dj-lazy-slot data-id="X"> and replaces it with the template's contents. Idempotent on double-fire. data-trigger="visible" defers the actual replacement until the slot enters the viewport via IntersectionObserver. data-status="error"/"timeout" wraps the fill in <dj-error aria-live="polite"> for screen-reader announcement.

    Sticky + lazy = TemplateSyntaxError at tag eval — hard incompatibility per ADR §"Failure modes". Sticky preservation requires the slot to exist at mount-frame time so the WS reattach can replaceWith the stashed subtree; lazy renders the slot AFTER mount, so the stash-target doesn't exist when reattach runs.

    as_view() dispatch wiringLiveView.as_view is now overridden so that classes with streaming_render = True return an async view callable (via markcoroutinefunction) that routes GET to aget() when in real ASGI context. This is the wiring that makes PR-A's foundation actually active end-to-end. WSGI deployments fall back to sync dispatch via sync_to_async, preserving the Phase-1 cosmetic chunked response behavior. The ASGI/WSGI signal is isinstance(request, ASGIRequest) — accurate even when the sync test Client wraps the async view via async_to_sync (the earlier loop-presence check was fooled by that wrapping).

    Files: python/djust/templatetags/live_tags.py (~210 LoC lazy= branch with thunk closure), python/djust/mixins/template.py (~40 LoC Phase-5 thunk loop), python/djust/mixins/request.py (~15 LoC thunk transfer + _lazy_thunks reset + ASGIRequest-aware _is_asgi_context), python/djust/live_view.py (~50 LoC as_view override). New: python/djust/static/djust/src/50-lazy-fill.js (~140 LoC client). 14 new cases in tests/unit/test_live_render_lazy.py cover validation, placeholder emit, thunk stash, thunk closure including error + timeout envelopes. 2 new integration cases in tests/integration/test_lazy_streaming_flow.py drive the full pipeline (sync render → thunk transfer → arender_chunks Phase 1-5 → consumer drain) and assert the body-close-before-fills wire-format ordering.

    Foundation for PR-C (asyncio.as_completed parallel render across thunks; closes #1043 umbrella).

  • Async render-path foundation: aget() + ChunkEmitter + arender_chunks() (v0.9.0 PR-A, ADR-015) — first PR of the v0.9.0 P2 streaming arc (#1043). Closes the v0.6.1 retro #116 doc-claim debt: Phase 1 was a regex-split-after-render with no real TTFB win; Phase 2 PR-A introduces the actual async render path so streaming_render = True shell-flushes to the wire BEFORE get_context_data() runs.

    New module python/djust/http_streaming.py (~230 LoC) provides the ChunkEmitter class — a per-request bounded asyncio.Queue with backpressure, cancellation propagation via request_token, and a register_thunk() API surface that PR-B ({% live_render lazy=True %}) will hook into. The emitter exposes __aiter__ for direct consumption by StreamingHttpResponse.

    New async def aget() on RequestMixin (~150 LoC) parallel to the existing sync get(). Wraps the sync render via sync_to_async(self.get) to produce the full HTML, then drives arender_chunks() to push chunks through the emitter. Returns a StreamingHttpResponse with X-Djust-Streaming: 1 and X-Djust-Streaming-Phase: 2 headers. ASGI disconnect watcher cancels the emitter when the client closes the connection.

    New arender_chunks() async coroutine on TemplateMixin (~135 LoC) splits the rendered HTML at <div dj-root> boundaries into 4 chunks (shell-open / body-open / body-content / body-close) and pushes each via emitter.emit() with await asyncio.sleep(0) boundaries so ASGI flushes the shell to the wire before the body chunks are queued. Cooperative cancellation via ChunkEmitterCancelled. Single-chunk fallback for fragment templates (no <div dj-root>).

    streaming_render = False (default) stays on the sync HttpResponse path. WSGI deployments fall back to the Phase-1 regex-split-after-render via _make_streaming_response per the documented graceful-degrade contract.

    Files: python/djust/http_streaming.py (new), python/djust/mixins/request.py (aget() + _is_asgi_context()), python/djust/mixins/template.py (arender_chunks()), docs/adr/015-phase-2-streaming.md (ADR promoted from .pipeline-state/feat-streaming-phase2-1043-adr-draft.md). 18 new test cases in tests/unit/test_async_render_path.py cover ChunkEmitter basics + backpressure + cancellation, arender_chunks 4-yield invariant + fragment fallback + mid-stream cancel, aget streaming response shape + redirect passthrough + non-streaming fallback, and _get_queue_max_from_settings defaulting.

    PR-B ({% live_render lazy=True %} user API) and PR-C (asyncio.as_completed() parallel render) ship on top of this foundation.

  • {% live_render ... sticky=True %} auto-detects preserved stickies (closes #1032, ADR-014) — the v0.6.0 Sticky LiveViews work shipped Dashboard→Settings→Reports preservation but left a known limitation: returning to a page that declares the sticky inline (Dashboard → Settings → Dashboard) re-mounted the child instead of reattaching the survivor — audio playback and any in-flight state on the sticky child died.

    The v0.9.0 P1 1.0-blocker fix teaches the {% live_render %} template tag to consult the consumer's _sticky_preserved registry at render time. When a survivor exists for the resolved sticky_id, the tag re-registers the survivor onto the new parent, marks the id in a new consumer._sticky_auto_reattached set, and emits a <dj-sticky-slot> placeholder rather than a fresh subtree. The consumer's existing slot scan + the client's existing replaceWith reattach then complete the round-trip without ever calling mount() on the survivor again.

    No wire-protocol changes. No new transport (cookie/header/handshake) needed — the existing WS pipeline already carries survivor info to the exact moment the tag renders. Falls through to fresh-mount unchanged on the HTTP GET path (no _ws_consumer back-reference) and on first-navigation (empty _sticky_preserved).

    Files: python/djust/templatetags/live_tags.py (~30 LoC tag-side branch), python/djust/websocket.py (_sticky_auto_reattached set init/reset + slot-scan skip-on-claim, ~12 LoC), docs/adr/014-sticky-liveview-autodetect.md (new ADR). 4 new cases in TestStickyAutoDetect in tests/unit/test_live_render_tag.py cover no-consumer, empty-preserved, preserved-for-our-id, and preserved-for-other-id paths. 2 new integration cases in tests/integration/test_sticky_redirect_flow.py drive the full Dashboard→Dashboard auto-reattach pipeline (tag emit

    • consumer slot-scan skip-on-claim + survivor in survivors_final) end-to-end through the existing _FakeConsumer rig.

[0.8.7rc1] - 2026-04-26

Fixed

  • DataTableMixin.get_table_context() post-mount missing show_stats key (closes #1118) — Stage 11 review of PR #1117 surfaced that show_stats was present in _PRE_MOUNT_TABLE_CONTEXT (the empty-table default returned before init_table_state() runs) but missing from the post-mount return dict. A template containing {% if show_stats %} would silently fall back to the falsy default pre-mount and then raise VariableDoesNotExist post-mount once init_table_state() had populated real state. One-line fix adds "show_stats": self.table_show_stats to the post-mount dict, alongside the existing printable / column_stats keys.

    Files: python/djust/components/mixins/data_table.py (one-key addition in get_table_context()); 2 new cases in PreMountGuardTest in python/tests/test_data_table_mixin_liveview.py cover post-mount default-False and class-override-True paths. The pre-existing test_pre_mount_default_has_required_template_keys symmetry test now passes against the fixed dict — that's the regression lock-in for any future post-mount key additions.

Changed

  • Process canonicalizations from the v0.8.6 retro arc folded into CLAUDE.md (closes #1122, #1123, #1124, #1125) — Five Stage 11 / retro-tracker learnings from PRs #1115 / #1117 / #1119 / #1120 are now canonicalized as additions to the existing "Process canonicalizations" section in CLAUDE.md. Each rule names the source PR so the audit trail is preserved.

    Topics covered: split-foundation pattern for high-blast-radius features (PR-A foundation + PR-B capability — validated 3× across the View Transitions arc, #1122); pre-mount/post-mount keyset invariant test pattern for mixins with default-state dicts (#1123); CodeQL js/tainted-format-string self-review checkpoint — use console.error('[label] msg %s:', val, errObj) not template literals when the label derives from user-controlled DOM data (#1124); bulk dispatch-site refactor PRs need N tests for N sites + a count-test guarding the EXPECTED list against drift (#1125); format-string hygiene in test assertions when the assertion is itself an f-string referencing caught exceptions (PR #1120 retro).

    Docs-only change. No code or test surface modified.

[0.8.6rc1] - 2026-04-26

Changed

  • Process canonicalizations from the v0.8.5 → v0.8.6 retro arc folded into CLAUDE.md (closes #1100, #1101, #1103, #1104, #1106, #1108, #1109) — Eight Stage 11 / retro-tracker learnings from the View Transitions PR-A → PR-B arc and the downstream-consumer gap-fix arc are now canonicalized as a single "Process canonicalizations" section in CLAUDE.md. Each rule names the source PR so the audit trail is preserved.

    Topics covered: completeness-grep after async-migration regex passes (#1100); ADR scope-estimation counts test-file callers (#1101); is None coalesce vs kwargs.setdefault for mixin kwarg-forwarding (#1103); mechanical-replacement PRs need N tests for N sites (#1104); CHANGELOG test-count phrasing for additions to existing files (#1106); Iterable[T] over list[T] for membership-check parameters (#1108); dynamic subclass via type(name, bases, dict) over class-attr mutation in test fixtures (#1109); microtask-faithful test stubs for startViewTransition / MutationObserver / IntersectionObserver (PR #1113 retro); batch-PR issue × file × test mapping table convention (PR #1115 retro).

    Docs-only change. No code or test surface modified.

Added

  • djust.C013 system check — stale collectstatic copy of client.min.js (closes #1088) — anyone with STATIC_ROOT configured (typical production deployment behind WhiteNoise / nginx / a CDN) can ship a stale client.min.js after a djust wheel upgrade if they forget collectstatic --clear. The server runs new code; the browser loads old client.js → wire-protocol skew → mysterious VDOM patch failures. #1081 was reopened twice before the reporter root-caused this structurally-recurring trap.

    C013 compares the SHA-256 of STATIC_ROOT/djust/client.min.js against the wheel-bundled copy at python/djust/static/djust/client.min.js. When they diverge, emits a Django system warning at startup with the exact fix command. No-op when STATIC_ROOT is unset, when the collected file is absent (pre-collectstatic), or when content matches. Honors DJUST_CONFIG = {"suppress_checks": ["C013"]} for users who serve client.min.js from a CDN or custom build.

    Files: python/djust/checks.py (new _check_stale_collected_client, wired into check_configuration); 5 cases in TestC013StaleCollectstatic in python/tests/test_checks.py cover no-STATIC_ROOT skip, no-collected-file skip, matching-content quiet, diverged-content warning, suppress-via-DJUST_CONFIG silence.

Fixed

  • |date and |time filters now debug-log on parse failure (closes #1090) — both filters previously fell through silently to the original value when chrono failed to parse the input string. The #1081 4-round-reopen investigation would have collapsed to a 5-minute diagnosis if a single line had been logged at parse-failure time. Now the failure is surfaced via tracing::debug! against target djust.templates.filters with the offending value, format string, and chrono error message.

    Enable via Python LOGGING['loggers']['djust.templates.filters'] = {'level': 'DEBUG'} or set RUST_LOG=djust.templates.filters=debug for the Rust-side tracing consumer. Behavior unchanged when the log target is disabled — just no longer a silent void.

    Files: crates/djust_templates/Cargo.toml (added tracing workspace dep), crates/djust_templates/src/filters.rs (|date arm at line ~248, |time arm at line ~284 — replaced Err(_) => Ok(value.clone()) with Err(e) => { tracing::debug!(...); Ok(value.clone()) }).

  • _flush_deferred_to_sse legacy-view guard now has a regression test (closes #1093) — Stage 13 review of PR #1091 flagged that the WS-side hasattr guard had a parallel test (test_flush_deferred_handles_view_without_drain_method) but the SSE-side did not. New test_sse_flush_deferred_handles_view_without_drain_method in python/djust/tests/test_defer.py mirrors the WS shape — a legacy view class without _drain_deferred must short-circuit cleanly without AttributeError.

  • Release wheel matrix expanded to cp313 + cp314 (closes #1089).github/workflows/release.yml previously built only cp310/cp311/cp312 wheels. Users on Python 3.13 or 3.14 fell back to source-compiling the sdist at pip install time, producing untested binaries whose runtime behavior could diverge from CI-tested cp312 (this was the root cause of #1081's first reopen — reporter on 3.14 hit a source- compiled _rust.cpython-314-darwin.so). Matrix now ships tested wheels for cp310–cp314 across Linux x86_64, macOS Intel + ARM, and Windows x86_64 (Windows still excludes 3.10 per the existing policy).

  • View Transitions API integration in applyPatches (PR-B / ADR-013) — Opt-in via <body dj-view-transitions>. When the browser supports document.startViewTransition() AND the body attribute is present AND the user has not requested prefers-reduced-motion: reduce, every server-driven VDOM patch is wrapped in a View Transition: the browser captures a pre-state frame, runs our patch loop, captures the post-state, and animates between them.

    Default cross-fade for free, with one body-level attribute. Shared- element morphs via view-transition-name CSS — animate matching named elements between two completely different DOM trees (the "card flies into hero on detail page" pattern). Custom animation timing/easing via ::view-transition-old(name) / ::view-transition-new(name) pseudo-elements — designer-driven, no JS.

    Browser support gate: Chrome 111+, Edge 111+, Safari 18+. Firefox graceful-degrades — patches still apply, no animation. ~85% of current djust users get the polish; the remaining ~15% see no regression. Re-evaluated on every patch so dynamic mid-session opt-in via document.body.setAttribute('dj-view-transitions', '') works.

    Failure path: when the wrap callback throws, the wrapper logs at ERROR, calls transition.skipTransition() to abandon the animation, and returns false so the existing full-re-render fallback at 02-response-handler.js:109 fires. The async signature shipped in v0.8.5rc1 (PR-A) is what makes the callback's microtask semantics observable — the previous attempt (PR #1092) used a sync callback and silently lost the boolean return.

    Why this matters: View Transitions enables wizard step morphs, modal open/close animations, navigation-primitive page transitions (free polish for the dj-prefetch work shipped in v0.7.0), list reorders, and tab-switch cross-fades — without per-component animation code or runtime JS animation libraries.

    Files: python/djust/static/djust/src/12-vdom-patch.js adds _shouldUseViewTransition() gate and refactors applyPatches into a thin wrap-or-direct dispatcher; the existing patch-loop body becomes _applyPatchesInner (sync — no behavior change inside). Cleanup: 03-websocket.js (2 sites) and 03b-sse.js (1 site) drop the now-redundant outer .catch() on handleMessage calls — the queue wrapper from #1098 already has an internal .catch(), so the outer was dead code (Stage 11 nit from PR #1112).

    New test file tests/js/view-transitions.test.js covers all four _shouldUseViewTransition branches (API present, opt-in absent, opt-in present, reduced-motion), success/empty/wrap-throws paths, microtask-deferral correctness (DOM is unchanged before await), dynamic mid-session opt-in toggle, and direct-path parity. The vitest stub invokes the callback in a microtask via await Promise.resolve() to mirror real-browser semantics — NOT synchronously like the failed PR #1092 stub.

    ROADMAP Phoenix LiveView Parity Tracker View Transitions API → shipped. Quick Win #23 closed.

Added

  • Async-tolerant dj-hook lifecycle dispatch (v0.8.6 enhancement cashing in PR-A async refactor)dj-hook lifecycle methods (mounted, updated, beforeUpdate, destroyed, disconnected, reconnected, handleEvent) may now be async. The dispatcher detects Promise return and chains .catch to log rejections via console.error — no Unhandled Promise Rejection in the browser console.

    window.djust.hooks.UserAvatar = {
        async mounted() {
            const res = await fetch(`/api/profile/${this.el.dataset.userId}`);
            const profile = await res.json();
            this.el.querySelector('img').src = profile.avatar_url;
        },
    };
    

    Fire-and-forget contract: the dispatcher does NOT await user hooks. Lifecycle callbacks fire-and-forget so user I/O can't block the render loop. Sync hooks behave exactly as before — strictly additive, no API change for existing hook code.

    Implementation: new _safeCallHook(fn, label, ...args) helper in python/djust/static/djust/src/19-hooks.js wraps the existing try/catch sites for each lifecycle path. 9 sync sites refactored to use the helper (mounted×2, beforeUpdate, updated, destroyed×2, disconnected, reconnected, handleEvent). New file tests/js/async_hooks.test.js with 5 cases cover sync-unchanged behavior + async-Promise-rejection-logging + fire-and-forget timing contract.

  • docs/website/guides/view-transitions.md — comprehensive guide for the View Transitions API integration shipped in v0.8.6 PR #1113 — covers the <body dj-view-transitions> opt-in, browser support matrix (Chrome 111+, Edge 111+, Safari 18+, Firefox graceful degrade), prefers-reduced-motion accessibility bypass, shared-element transitions via view-transition-name, custom animation timing via ::view-transition-old(name) / ::view-transition-new(name) pseudo-elements, await window.djust.applyPatches(...) as public API for third-party JS, and a critical "JSDOM stub microtask correctness" section (mirroring the regression class that bit PR #1092). Linked from _config.yaml and index.md per the docs-nav convention.

  • {% data_table %} link column type (closes #1110) — column dicts now accept a link key naming another row dict key that holds the href, and an optional link_class for the <a> element's CSS class:

    table_columns = [
        {"key": "claim_number", "label": "Claim #", "link": "claim_url",
         "link_class": "claim-link"},
    ]
    # row dicts include both keys:
    {"claim_number": "2026PI000001", "claim_url": "/claims/1/", ...}
    

    Renders as:

    <td><a href="/claims/1/" class="claim-link">2026PI000001</a></td>
    

    Falls through to plain text when col.link is unset — strict backwards-compat with pre-#1110 column dicts. Replaces the _inject_link_column regex post-process workaround downstream consumers had to maintain (e.g. downstream-consumer).

  • {% data_table %} row-level navigation: row_click_event + row_url (closes #1111) — the entire <tr> becomes clickable for navigation. Two API shapes:

    Option B (preferred — LiveView-idiomatic): row_click_event fires a djust event with data-value=row[row_click_value_key]. Default value key is "id"; override per-table for slug-based routing:

    table_row_click_event = "open_claim"
    table_row_click_value_key = "uuid"
    
    @event_handler()
    def open_claim(self, value: str = "", **kwargs):
        self.redirect(reverse("claims:detail", kwargs={"claim_id": value}))
    

    Option A (static URL fallback): row_url names a row dict key containing the href; the <tr> gets data-href + an onclick that reads this.dataset.href and navigates:

    table_row_url = "claim_url"
    

    Both options also wire style="cursor:pointer" on each <tr> for the affordance. row_click_event takes precedence when both are set. Mirrored in DataTableMixin via table_row_click_event, table_row_click_value_key, and table_row_url class attributes, threaded through get_table_context() and _PRE_MOUNT_TABLE_CONTEXT.

    Security note for Option A (row_url): the URL flows into JS via onclick="window.location=this.dataset.href". Only assign developer-controlled URLs (typically computed from reverse()); user-controlled strings could enable javascript: URI execution. CSP note: Option A requires 'unsafe-inline' in script-src; prefer Option B (LiveView event) when CSP is strict. Option B is CSP-clean — the click is dispatched via the existing djust event pipeline, no inline JS executed.

    14 regression cases in python/tests/test_data_table_link_row_nav.py cover: link-column emits <a>; link_class flows through; no-link pre-#1110 compat; row_click_event adds dj-click to every <tr>; row_click_value_key overrides default id; absent row_click_event → no <tr> dj-click (compat); row_url adds data-href + JS; row_click_event precedence over row_url; mixin class-attr defaults; per-view override; pre-mount default + post-mount context

    • template-tag function include all 3 new keys.

Fixed

  • DataTableMixin LiveView compatibility — pre-mount guard + @event_handler() decoration on all on_table_* methods (closes #1114) — using DataTableMixin in a LiveView (rather than a Component) caused a blank/empty table on every page load even when refresh_table_server() correctly populated self.table_rows in mount(). Three compounding root causes:

    1. BUG-06 pre-mount lifecycle: djust's WebSocket consumer calls get_context_data() (which often calls get_table_context()) BEFORE mount() runs, to build the initial Rust VDOM snapshot. init_table_state() hadn't run yet, so self.table_rows didn't exist and get_table_context() raised AttributeError. djust caught it silently → empty initial VDOM → all subsequent VDOM patches diff against empty content → wrong renders.
    2. Missing @event_handler() decoration: on_table_sort, on_table_search, and 19 other handlers were plain methods. djust's default event_security="strict" rejected them — every consumer had to write wrapper boilerplate.
    3. Documentation gap: the API boundary between Component and LiveView use cases wasn't called out anywhere in the mixin's docstring.

    Fix: get_table_context() now guards on hasattr(self, "table_rows") and returns _PRE_MOUNT_TABLE_CONTEXT (a module-level minimal default with every key the {% data_table %} template tag reads — ~80 keys covering all 5 phases). All 21 on_table_* handlers now carry @event_handler() decoration. Mixin docstring expanded with a "LiveView vs Component lifecycle" note + recommended pattern for large datasets (pass queryset directly via get_context_data(), define @event_handler() methods on the view).

    Downstream impact: downstream-consumer PR #189 attempted migration and hit this; PR #191 reverted to native handlers. With this fix, DataTableMixin is usable from LiveView subclasses without per-handler boilerplate.

    8 regression cases in python/tests/test_data_table_mixin_liveview.py cover: pre-mount call doesn't raise; pre-mount returns the default; post-mount returns real state; pre-mount key set is a superset of post-mount (catches future post-mount additions that forgot to update the default); all 21 expected handlers have _djust_decorators metadata; handler count matches expected (catches future additions that forgot decoration); docstring mentions LiveView lifecycle and @event_handler() decoration (catches doc-rot).

  • handleMessage interleaving across await boundaries (closes #1098) — PR-A (v0.8.5rc1) made LiveViewWebSocket.handleMessage and LiveViewSSE.handleMessage async without serializing the inbound frame queue. Two adjacent inbound frames could fire-and-forget _handleMessageImpl concurrently and interleave their await handleServerResponse calls — racing on shared state like _pendingEventRefs / _tickBuffer (03-websocket.js:561-568 reads .size AFTER an await, so an in-flight second message could mutate the set between check and flush). Latent today; would have been meaningfully worse when PR-B (View Transitions wrap) widened the await window inside applyPatches itself.

    Fix: per-transport _inflight Promise chain. Each handleMessage(data) invocation chains onto the prior in-flight promise. Sequential drain across rapid-fire frames; no interleaving. Errors propagate through .catch() (logged via console.error) so the chain continues even when one frame rejects — a single bad frame doesn't poison the queue.

    Existing async handleMessage body renamed to _handleMessageImpl (private). New public handleMessage(data) is a thin wrapper that enqueues onto this._inflight. Both transports (WebSocket + SSE) apply the same pattern.

    New regression file tests/js/handlemessage_serialization.test.js covers: rapid-fire ordered drain (later messages with shorter delays must NOT finish first); throwing message doesn't poison the chain; returned promise resolves only after this frame drains; both WS and SSE expose handleMessage and _handleMessageImpl separately and serialize.

    Caller-side test migration: 4 existing test files updated to await the now-queued handleMessage calls (dj-cloak, hvr, sse-transport, sw_advanced) — same kind of un-awaited-call gap that Stage 11 caught on PR #1099. 1402 JS tests pass; 2080 Python tests pass.

    PR-B (View Transitions wrap) is now unblocked.

[0.8.5rc1] - 2026-04-26

Added

  • WizardMixin.wizard_rendered_fields opt-in skips field_html rendering for fields not in the list (closes #1097)WizardMixin.get_context_data() unconditionally pre-rendered field_html for every field on the current step's form, regardless of whether the template referenced that field. Wizards with conditional fields (e.g. owner-info hidden behind is_vehicle_owner == "no") paid the rendering cost on every event for fields nobody ever sees. Reported impact on the downstream-consumer VPD wizard: 115ms template render (threshold: 50ms), 47 VDOM patches per autofill — most for invisible inputs.

    New API (default behavior unchanged — None renders all):

    • Class-level: wizard_rendered_fields = ["first_name", "vin", ...] on the wizard view limits field_html to that subset across every step.
    • Per-step override: a step dict can include {"name": "...", "form_class": ..., "rendered_fields": [...]} to scope the filter to that step. Wins over the class-level default.

    form_data, form_required, and form_choices are NOT filtered — all fields remain part of validation/state. Only the (expensive) HTML rendering is opt-in skipped. Excluded field names produce no field_html[fname] entry; templates that reference them via {{ field_html.unused|safe }} render empty (the dict-key absence is intentional and visible).

    Files: python/djust/wizard.py (class attribute, per-step lookup + filter in get_context_data()); python/tests/test_wizard_rendered_fields.py with 8 cases in DefaultRendersAllFieldsTest, ClassAttributeFiltersTest, PerStepOverrideTest.

    Future direction: a smarter automatic template-scan (similar to the JIT serializer's used-field detection) could drive this without explicit developer wiring. This PR ships the explicit escape hatch first.

  • WizardMixin.wizard_input_event class attribute + dom_event kwarg on as_live_field() — configurable DOM event for live-field validation binding (closes #1095)WizardMixin.as_live_field() previously emitted dj-change="<handler>" unconditionally on text/textarea/select/checkbox/ radio inputs. dj-change fires only on blur, so a user who edits a pre-filled field and clicks Next without tabbing away has their edit silently discarded. Wizards with autofill or pre-filled-from-database fields hit this routinely.

    New API: a class-level default and a per-call override.

    Class default::

    class MyWizard(WizardMixin, LiveView):
        wizard_input_event = "dj-input"   # default: "dj-change"
    

    Per-call override::

    view.as_live_field("email", dom_event="dj-input")
    

    dj-input fires on every keystroke (300ms client-side debounce already in 09-event-binding.js), so edits land regardless of whether the user blurs first. Per-call kwarg wins over the class attribute.

    Default behavior unchanged ("dj-change"), so this is a strictly additive opt-in — existing wizards see no behavior change. Replaces the regex post-process workaround that downstream consumers (e.g. downstream-consumer PR #185) had to maintain.

    Files: python/djust/wizard.py (class attribute, as_live_field forwards dom_event through kwargs.setdefault), python/djust/ frameworks.py (5 sites — _render_input text/textarea/select, _render_checkbox, _render_radio — read kwargs.get("dom_event", "dj-change") instead of hardcoding "dj-change").

    14 regression cases in python/tests/test_wizard_input_event.py cover: default class attribute is "dj-change"; default rendering on text/textarea/select/checkbox/radio emits dj-change; per-call dom_event="dj-input" swaps to dj-input and removes dj-change; wizard_input_event = "dj-input" flows through as_live_field(); per-call kwarg overrides class attribute; dom_event=None coalesces to the class attr instead of producing attrs[None].

  • self.defer(callback, *args, **kwargs) — Phoenix-style post-render callback scheduling — new method on AsyncWorkMixin (and therefore on every LiveView) that schedules a callback to run once, after the current render+patch cycle completes. Phoenix send(self(), :foo) / React useEffect (post-render) parity. Fires synchronously in the same WebSocket message cycle (after _send_update returns) — so deferred callbacks observe the post-patch state. Use cases: telemetry emission after the user sees the change, post-render cleanup of temporary state, scheduling follow-up side effects without re-rendering.

    Differs from start_async: defer does NOT trigger a re-render after the callback returns (the caller would use start_async for that), and runs synchronously in the same WS frame rather than spawning a background thread. Append-only queue: every defer() call adds to a per-view list that is drained and cleared by LiveViewConsumer._flush_deferred() after every _send_update() call (10 sites in python/djust/websocket.py, mirroring the existing _flush_push_events / _flush_flash / _flush_page_metadata / _flush_pending_layout post-render-flush pattern).

    Async callbacks (async def or coroutine-returning) are awaited inline. Exception isolation: a failing deferred callback is logged at WARN with full traceback and execution continues to the next callback in the queue — a deferred callback's failure must not break the WebSocket connection or the user's interactive flow. 19 regression cases in python/djust/tests/test_defer.py cover queue mechanics (append/drain/clear), arg/kwarg passing, ordering, sync+async mix, exception isolation, edge cases (no view_instance, view without AsyncWorkMixin), drain-reentry contract (a callback that calls defer(other) enqueues other for the next drain — Phoenix-style, prevents unbounded loops), and SSE transport integration (mirror flush via _flush_deferred_to_sse() in python/djust/sse.py).

    Example::

    class CounterView(LiveView):
        @event_handler
        def increment(self, **kwargs):
            self.count += 1
            self.defer(self._record_metric, action="increment")
    
        def _record_metric(self, action: str):
            # Fires AFTER the patch reaches the client.
            metrics.increment(f"liveview.{action}", count=self.count)
    

    Phoenix LiveView Parity Tracker entry self.defer() (post-render) marked shipped in ROADMAP.md.

Changed

  • VDOM applyPatches signature is now async (returns Promise<boolean>) — foundational refactor preparing for View Transitions API integration (ADR-013). Previously applyPatches(patches, rootEl) returned boolean synchronously; now async function applyPatches(patches, rootEl) -> Promise<boolean>. The patch-loop body itself is unchanged — this is a signature-only migration. Direct caller migration covers six call sites across the client modules: 02-response-handler.js, 03-websocket.js, 03b-sse.js, 11-event-handler.js, 45-child-view.js. Each awaits applyPatches and propagates async upward — handleServerResponse is now async, LiveViewWebSocket.handleMessage and LiveViewSSE.handleMessage are now async, and the EventSource onmessage arrow callbacks (which cannot be async in their declared form) wrap their handleMessage invocations in .catch() to preserve unhandled-rejection visibility. _applyScopedPatches, handleChildUpdate, and handleStickyUpdate in 45-child-view.js are also async.

    Why this signature change matters: document.startViewTransition()'s callback runs in a microtask after the browser captures the pre-patch frame, NOT synchronously, so any wrapping that schedules patches via startViewTransition requires the patch function to be awaitable. PR-A (this entry) is the foundation; PR-B will add the View Transitions wrap on top without further signature changes.

    No external API change for view authors — VDOM internals only. Newly exposed public surface: window.djust.applyPatches is now explicitly assigned via globalThis.djust.applyPatches = applyPatches at the end of 12-vdom-patch.js. (Previously the function was reachable in test environments only by eval-host-scope hoisting, which async declarations don't honor under JSDOM.) Hook code that monkey-patches applyPatches should now address the namespace explicitly and treat the return value as a Promise<boolean>.

    Test surface migrated: 8 JS test files updated to await applyPatches / handleMessage / handleServerResponse calls and switch to dom.window.djust.applyPatches. 1396 JS tests pass; 4230 Python tests pass; behavior parity with the previous sync signature confirmed by the existing patch test suite (vdom_patch_errors.test.js, vdom_recovery.test.js, tab_switch_real_repro.test.js, event_sequencing.test.js, batch_insert_before_remove.test.js, vdom-autofocus.test.js, sse.test.js).

Fixed

  • djust.T012 false positive on {% include %} partial templates (closes #1096)T012 (template uses dj-* event directives but missing dj-view) fired unconditionally for any template containing dj-click, dj-input, etc., even when the file was an intentional fragment included from a parent LiveView root. Wizards with 15+ step partials produced a noisy 15-warning wall in manage.py check.

    Two opt-out paths now silence T012 for legitimate fragments:

    1. Per-template marker: add {# djust:partial #} (case-insensitive, whitespace flexible) anywhere in the template. The marker is the right choice when most fragments in a project don't need the check but a few full-page templates do.
    2. Global suppression: DJUST_CONFIG = {"suppress_checks": ["T012"]} in settings.py. Right when the project never uses T012's intended diagnostic (e.g. component-only architectures).

    T012's hint now mentions both options. Component templates (dj-component present) continue to bypass T012 as before — pre-existing behavior unchanged.

    Files: python/djust/checks.py (new _DJ_PARTIAL_MARKER_RE, T012 guard reads partial marker AND _is_check_suppressed("djust.T012") — previously the global suppression infrastructure existed but T012 wasn't wired in). New cases added to TestT012EventDirectivesWithoutView in python/tests/test_checks.py cover: partial marker silences T012; case-insensitive matching; global suppression via short ID ("T012") and qualified ID ("djust.T012"); hint text mentions both opt-out paths.

  • scripts/check-changelog-test-counts.py regex missed async def test_* — the test-counter pre-push hook's PY_TEST_FN_RE matched only def test_*, silently undercounting pytest-asyncio test files (any module-level async def test_* was invisible). Updated the pattern to ^[ \t]*(?:async\s+)?def\s+test_\w+\s*\( so async tests are counted alongside sync tests. Surfaced via tests/test_defer.py (7 sync class-method tests + 7 module-level async tests = 14 total; pre-fix the hook reported 7 and the CHANGELOG claim of "14 regression cases" tripped a false drift warning). Mechanical fix; no behavior change for files that don't use async def test_*.

[0.8.4rc1] - 2026-04-26

Fixed

  • Inheritance resolution doubled filter-arg quotes — |date:"M d, Y" rendered as &quot;Apr 25, 2026&quot; (closes #1081)nodes_to_template_string in crates/djust_templates/src/inheritance.rs was wrapping every filter arg in \"…\" when serializing the resolved-inheritance AST back to a template string. But parse_filter_specs deliberately preserves any surrounding quotes on literal args (the dep-tracking extractor needs them to disambiguate literals from bare-identifier variable references — see #787). So an arg parsed from |date:"M d, Y" came out of the parser as the string "M d, Y" (with the quote chars), and the round-trip wrapped it again to produce |date:""M d, Y"". Re-parsing the resolved template then stripped the outer pair, leaving the inner "M d, Y" as the format spec; chrono treats " as literal output characters in strftime-style formats, so the rendered date came out as "Apr 25, 2026", then HTML-escape converted the " to &quot; — surfacing as &quot;Apr 25, 2026&quot; in the rendered DOM. The fix emits the arg verbatim (|filter:{arg}) since parse_filter_specs already preserves the source-form quotes; round-trip is now idempotent. Same fix applied to the Node::InlineIf branch (a {{ x if cond else y | filter:"…" }} chain has the same shape). 29 regression cases in tests/unit/test_filter_literal_args_1081.py + 3 in crates/djust_templates/src/inheritance.rs lock the round-trip invariant. Surfaced via PR #1086 against an actual 26,785-char inheritance-resolved template (<style> blocks with quoted CSS font names + the date filter). Failure mode was inheritance-resolution-specific: simple inline templates (no {% extends %}) never hit nodes_to_template_string and rendered correctly all along — which is why the simple-template regression suite passed but production templates with inheritance failed.

Added

  • Regression tests locking literal filter-arg quote stripping (#1081) — issue reported {{ d|date:"M d, Y" }} rendering as &quot;Apr 25, 2026&quot; (literal double-quotes wrapping the result) and {{ x|default:"fallback" }} rendering as &quot;fallback&quot;. Investigation across all renderer code paths confirmed the existing strip_filter_arg_quotes helper (landed v0.5.2rc1 via #787) is invoked at every filter-application site: render_node_with_loader (Variable + InlineIf nodes, both call sites at crates/djust_templates/src/renderer.rs:271,328) and get_value for inline filter chains (renderer.rs:1556 — inline arg_str = arg_str[1..len-1] strip). When the issue was reopened with a more specific reproduction path ("Django DateField from a model passes through the Rust context serializer before being filtered, output is inserted as JSON string value into VDOM"), re-tested the named path and confirmed serialize_context (crates/djust_live/src/lib.rs:1776-1781) returns the bare ISO string — value.call_method0("isoformat") is passed straight through into_pyobject with no serde_json::to_string or quote-wrapping. No reproducible code path produces the reported output on main (= v0.8.3rc1). New tests/unit/test_filter_literal_args_1081.py ships 24 cases covering every literal-arg shape from the issue body, follow-up comments, and reopen: (1) |date with "M d, Y" / "F j, Y" / single-quoted format / dotted-path field access; (2) |default with simple word / multi-word / slash / em-dash / dash / "No" / single-quoted / truthy passthrough / None fallback; (3) chains (|date:"…"|default:"…", |default:"…"|upper); (4) HTML attribute context (where any leftover literal quote would surface as &quot;); (5) serialize_context output shape — bare ISO string for date / datetime / list-of-dicts (the queryset+model+date path named in the reopen); (6) full LiveView.render() with Django Model + DateField via the JIT serializer; (7) LiveView.render() with list of Model instances (_jit_serialize_queryset / _jit_serialize_model path); (8) render_with_diff full + partial (the WS-update path the reopen described as inserting JSON-quoted values into the VDOM). Locks the invariant against future renderer / VDOM-patch / JIT-serializer / context-serializer refactors so the JSON-quoting class of bug cannot silently re-emerge.

Changed

  • ROADMAP.md staleness sweep (post-v0.8.3rc1) — verified each unchecked Priority Matrix row, Quick Wins bullet, Medium Effort bullet, Major Features bullet, and Phoenix LiveView Parity Tracker row against the codebase. Marked ~30 items with ✅ + strikethrough + the actual implementation path (e.g. static/djust/src/26-js-commands.js for JS Commands, python/djust/streaming.py for AI streaming primitives, crates/djust_vdom/src/parser.rs for keyed for-loop change tracking). Annotated the genuinely-pending items with *(verified: no … references in tree)* so the next person to triage doesn't re-discover the same false signals. Items confirmed shipped: JS Commands, Flash messages, on_mount hooks, Function components, assign_async/AsyncResult, Template fragments, Keyed for-loop change tracking, Temporary assigns, dj_suspense, Named slots with attributes, Server Actions (@action), Async Streams, Keep-Alive/dj-activity, WebSocket compression, dj-track-static, dj-no-submit, page_loading on push, dj-sticky-scroll, dj-paste, dj-ignore-attrs, handle_params, handle_async, Hot View Replacement, dj-lock, dj-auto-recover, dj-cloak, dj-copy, Scoped JS selectors, Component update callback, Nested components (LiveComponent), Targeted events (dj-target), Declarative assigns, Selective re-rendering (VDOM partial), handle_info, Animations (dj-transition), Transition groups (dj-transition-group), Exit animations (dj-remove), DOM mutation events (dj-mutation), Sticky scroll, CSP nonce, Viewport events, Direct-to-S3 uploads, Prefetch on hover/intent (dj-prefetch), Server functions (@server_function), Push navigate, Back/forward restoration, Paste event handling, Scroll into view, AI streaming primitives. Items confirmed genuinely-pending (with greppable evidence): View Transitions API, used_input?, @rest attribute spread, self.defer(callback), Multi-tab sync (BroadcastChannel), Offline mutation queue, State undo/redo, Connection multiplexing, Portal rendering, Server-only components, Islands of interactivity, i18n live language switching. Docs-only change; no runtime behavior.

[0.8.3rc1] - 2026-04-25

Added

  • make docs-lint — sweep docs//*.md for stale cross-references (closes #1075)** — new scripts/docs-lint.py walks every markdown link in docs/ (excluding the rendered docs/website/ site dir), parses [text](target.md) patterns, and reports any whose relative target doesn't resolve. Mirrors make roadmap-lint from Action #142 — manual make docs-lint, with optional VERBOSE=1 to list every stale ref. Also wired into .pre-commit-config.yaml as a pre-push hook so the stale-ref class can't regress.

Fixed

  • 53 stale .md cross-references across 16 files in docs/ (closes #1075) — follow-up to #1010. Sweep found 53 broken refs across 16 files: 34 relocatable (file moved to a different docs/ subdir; rewrote relative path), 12 marketing-cluster files that no longer exist (unlinked — kept link text without the [](url) syntax), 7 references to forms/PYTHONIC_FORMS_IMPLEMENTATION.md redirected to the canonical docs/website/guides/forms.md. Fixer script at /tmp/scratch/fix_stale_md_refs.py (one-shot; not committed). After fix: 0 stale refs remaining.

[0.8.2rc1] - 2026-04-25

Added

  • {% theme_css_link %} cache-busting helper tag (v0.8.2 drain — Group T, closes #1012) — Chrome's Vary: Cookie handling is unreliable for per-cookie dynamic CSS; after a pack switch the browser often serves the prior pack's stylesheet from its own HTTP cache and the page renders with stale palette. The new {% theme_css_link %} tag in djust.theming.templatetags.theme_tags emits <link href="/_theming/theme.css?p=<pack>&m=<mode>&r=<preset>"> with cache-busting query params derived from the same ThemeManager.get_state() the view itself reads. Different pack/mode = different URL = guaranteed fresh fetch. Usage: <link rel="stylesheet" href="{% theme_css_link %}">.

  • prose.css for @tailwindcss/typography ↔ pack bridge (v0.8.2 drain — Group T, closes #1009) — new djust_theming/static/djust_theming/css/prose.css ships pack-aware overrides for the typography plugin's --tw-prose-* variables. Opt in by adding prose-djust alongside prose on your <article>. Reads --color-brand-* tokens the active pack emits, so flipping packs at runtime updates prose without a stylesheet swap. Includes both light-mode and dark-mode invert variables. Pulled from docs.djust.org's reference implementation. ~95 lines.

  • enable_client_override flag for LIVEVIEW_CONFIG['theme'] (v0.8.2 drain — Group T, closes #1013)ThemeManager.get_state() reads djust_theme_pack / djust_theme_preset cookies with priority over config defaults. Default behavior unchanged (back-compat True). Sites without a user-facing theme switcher can set LIVEVIEW_CONFIG['theme']['enable_client_override']: False to ignore cookie reads — prevents cross-project bleed on localhost where multiple djust apps share a cookie jar.

Fixed

  • .card / .alert overflow:hidden for clean rounded corners (v0.8.2 drain — Group T, closes #1011)djust_theming/static/djust_theming/css/components.css .card and .alert selectors now set overflow: hidden. Without this, child borders (e.g. .card-header { border-bottom: ... }) cross the parent's rounded arc and produce a visible 1-2 px notch at the corners. Affects every theme pack.

  • mount_batch fallback for old-server compat (v0.8.1 reconcile drain — Group F, closes #1031) — the mount_batch WebSocket frame was added in v0.6.0 (PR #970) for lazy- hydration efficiency. A v0.6.0+ client talking to a pre-v0.6.0 server previously got a generic "Unknown message type: mount_batch" error and the lazy-hydrated views never mounted. Now the client tracks the in-flight batch in lazyHydrationManager.inFlightBatch; if the websocket error handler sees mount_batch or Unknown message type in the error string, it invokes handleMountBatchFallback() which iterates the stashed mounts and falls back to per-view mount calls. Idempotent (clears inFlightBatch before iterating) so a late-arriving successful response can't double-trigger. 7 new JSDOM tests under tests/js/mount-batch-fallback.test.js.

Security

  • Drop exception text from JSON-parse error responses (v0.8.1 reconcile drain — Group B, closes #1026)python/djust/api/dispatch.py (two sites at the API event-dispatch and server-function paths) was returning f"Malformed JSON body: {exc}" — a small but real stack-trace-style leak that could surface parser internals (offsets, snippets of the malformed input) to the client. Aligned to match observability/views.py:401's existing pattern: log the exception server-side via logger.exception(...), return a generic "Malformed JSON body — see server logs" message. The invalid_json error code is unchanged, so callers that branch on error keep working.

Changed

  • WebSocket cache-write failures now log under djustDebug (v0.8.1 reconcile drain — Group B, closes #1030)python/djust/static/djust/src/03-websocket.js:386 previously swallowed cache-put exceptions with a bare catch (_e) {}. Now logs the failure via if (globalThis.djustDebug) console.log(...) so developers can diagnose cache-write misses without polluting production console output.

  • Test infrastructure cleanup (v0.8.1 reconcile drain — Group A, closes #1027, #1028, #1034, #1036) — four small test-quality refactors bundled in one PR:

    • #1036: _assert_benchmark_under and the per-segment budget constants (TARGET_PER_EVENT_S, TARGET_LIST_UPDATE_S, TARGET_WS_MOUNT_S) moved from tests/benchmarks/test_request_path.py into tests/benchmarks/conftest.py for shared scope across benchmark files.
    • #1034: replaced the TARGET_LIST_UPDATE_S * 20 magic-number budget for the WS-mount benchmark with a named TARGET_WS_MOUNT_S = 0.1 constant — rationale lives in the constant name, not the multiplier.
    • #1028: extracted the duplicated _make_user factory into python/djust/tests/conftest.py as make_staff_user(...). Two test files (test_admin_widgets_per_page.py, test_bulk_progress.py) now import the shared factory.
    • #1027: replaced the inspect.getsource-based regression test in test_stack_trace_exposure.py with a behavior-level test that triggers a serialize-error via a sentinel-laden RuntimeError and asserts neither the sentinel nor the exception class name reach the response body. Defends against regressions even if the leak vector moves.

Added

  • make roadmap-lint — mechanical ROADMAP-vs-codebase drift check (Action #142, closes #1057)scripts/roadmap-lint.py parses the "Not started" entries in ROADMAP.md, extracts grep-able tokens from each feature name, and reports entries whose tokens have zero hits in code paths (python/, crates/, static/, scripts/, tests/, Makefile). Pure mechanical check — for semantic auditing (LLM reads each entry, decides if the cited feature actually ships) use the pipeline-roadmap-audit skill instead. Exit code 0 unless drift exceeds threshold (25 suspect entries). Run via make roadmap-lint or make roadmap-lint VERBOSE=1.

  • Pre-push hook for # noqa: F822 in __all__ patterns (Action #146, closes #1061)scripts/check-noqa-f822.sh flags new noqa: F822 annotations introduced in python/**/*.py or tests/**/*.py since the last push. Ruff silences py/undefined-export with noqa: F822, but CodeQL flags it as a security alert later — the canonical fix is a TYPE_CHECKING-conditional import (PR #924 pattern). Hook fires only on changed files (incremental); pass --all to scan the whole tree manually.

[0.8.0rc1] - 2026-04-25

Added

  • @action decorator — React 19 Server Actions equivalent (v0.8.0) — mark a method as a Server Action and _action_state[<method_name>] is auto-populated with {pending, error, result} at handler entry/exit. Templates access the state via context injection: each action's name becomes a context variable. Pairs with the v0.8.0 dj-form-pending attribute (PR #1023): dj-form-pending covers the in-flight client UX (during the network round-trip), @action covers the post-completion server state (after the handler returns). Together: React 19-level form ergonomics with zero per-handler wiring.

    from djust import action
    
    class TodoView(LiveView):
        @action
        def create_todo(self, title: str = "", **kwargs):
            if not title:
                raise ValueError("Title is required")
            todo = Todo.objects.create(title=title, user=self.request.user)
            return {"created": todo.id}
    
    {% if create_todo.error %}
        <div class="error">{{ create_todo.error }}</div>
    {% elif create_todo.result %}
        <div class="success">Todo {{ create_todo.result.created }} created!</div>
    {% endif %}
    

    Implementation:

    • New @action decorator in djust.decorators. Wraps the underlying @event_handler (every action is also an event handler — same dispatch path, parameter coercion, permissions, rate limits) and adds the action-state tracking layer.
    • On entry: self._action_state[name] = {pending: True, error: None, result: None}.
    • On success return: {pending: False, error: None, result: <return_value>}.
    • On exception: {pending: False, error: str(exc) or exc.__class__.__name__, result: None} and re-raises.
    • LiveView.__init__ initializes _action_state: Dict[str, Dict] = {}.
    • ContextMixin.get_context_data() injects each action's state under its name (after the public-attribute walk + JIT serialization, so action names that collide with user-defined attrs win — actions are always the canonical reading of that name).
    • Re-running an action resets state (clears previous result on a failure retry, clears previous error on a success retry — the template never sees stale state alongside fresh state).
    • Both bare-form @action and called-form @action(description=...) supported.
    • New is_action(func) helper for runtime detection.
    • Exposed as top-level imports: from djust import action, is_action.

    Covered by 18 regression tests in tests/test_action_decorator.py (decorator metadata + event-handler/action distinction, sync success / exception / re-raise / class-name fallback, multiple actions independent state, retry success-after-failure + failure-after-success, both decorator forms, end-to-end context injection via ContextMixin.get_context_data()).

  • dj-form-pending attribute — React 19 useFormStatus equivalent (v0.8.0) — any element nested inside a <form dj-submit> can declare dj-form-pending="hide|show|disabled" and react automatically when the ancestor form's submit handler is in-flight. No prop drilling, no per-button wiring, no client-side state. The form itself gets a data-djust-form-pending="true" attribute while pending so CSS selectors (form[data-djust-form-pending] .spinner) can hook in without JS. Modes:

    • hide — element is hidden via the hidden attribute while pending (idle label that disappears during submit)
    • show — element is hidden by default and visible while pending (loading spinner / "Saving…" text)
    • disableddisabled = true while pending; original disabled state restored on resolve. User-disabled elements stay disabled (the helper tracks pre-pending state in data-djust-form-pending-was-disabled).

    State is set BEFORE the network round-trip and cleared in a finally block so it always resolves regardless of error. Scope isolation: only [dj-form-pending] descendants of the actually- submitting form react; sibling <form dj-submit> forms on the same page are unaffected. Unknown modes are silently ignored (forward-compatible). Implemented in 09-event-binding.js_setFormPending(form, pending) helper + 1-line wiring into _handleDjSubmit. Bundle delta: ~80 B gzipped. Covered by 8 JS regression tests in tests/js/dj-form-pending.test.js (data-djust-form-pending toggle, hide/show/disabled modes, user-disabled preservation, plain-form no-op, scope isolation, error-path cleanup, unknown-mode forward-compat).

[0.7.4rc1] - 2026-04-25

Documentation

  • Check-authoring guide + PR review checklist additions (v0.7.4, closes #1017, #1018, #1019, #1020) — four retro follow-ups from v0.7.2 + v0.7.3 milestones bundled into a single docs PR. New file docs/development/check-authoring.md documents two reusable patterns surfaced during the v0.7.x check-refinement work:

    • Whitespace-preserving redaction for line-number-aware regex scanners (canonical: _strip_verbatim_blocks from PR #1014). Reusable for any future check that scans template source as raw text and needs to ignore a region ({% verbatim %}, {% comment %}, <script>, fenced markdown blocks). Replace body with whitespace, preserve newlines for line-number accuracy.
    • Config-driven check scope helper extraction (canonical: _contrast_check_scope / _presets_to_check from PR #1015). When a check's behavior depends on a user-configurable scope, extract the decision into a named helper so the four-branch test seam (default / opt-in-all / missing-scope-target / unknown-value) is testable without dragging in the full Django settings stack. Documents the safe-default contract: unknown config values fall back to the signal-preserving option.

    PR review checklist (docs/PULL_REQUEST_CHECKLIST.md) gains two new bullets:

    • Misleading existing tests are part of the bug — when fixing a check, audit existing tests for fixtures that exemplify the broken behavior; update them, don't just add new tests alongside. Source: PR #1008 (issue #1003).
    • Framework-internal attrs filter sync — new framework-set attrs on LiveView / LiveComponent must be added to _FRAMEWORK_INTERNAL_ATTRS to prevent leakage into get_state(). Source: ADR-012 / issue #962 / PR #1002.

Fixed

  • py3.14 timing-sensitive CI flake class (v0.7.4, #1016) — two tests intermittently failed on the py3.14 CI runner only: python/tests/test_hotreload.py::TestHotReloadMessage::test_hotreload_slow_patch_warning (PR #1001 caught it once; passed on rerun) and python/tests/test_realtime_multiuser.py::TestPerformanceBaseline::test_broadcast_latency_scales[10] (PR #990 caught it once; passed on rerun). py3.12/3.13 passed both attempts in both cases. Two distinct fixes, one PR:

    • test_hotreload_slow_patch_warning: the original mock used a fixed 6-element times array indexed by time.time() call count. py3.14 introduced extra time.time() calls inside the asyncio scheduler path (some loop.time() chains delegate down), so the call count drifted past the array on py3.14 only, leaving every subsequent call returning the last array value (0.15) — which kept the elapsed delta at 0 and prevented the slow-patch warning from firing. Replaced with a phase-based scheme: first two calls return 0.0 (start + render-start), every subsequent call returns 0.15 (render-end / total-end). The slow-patch threshold (>100 ms) is crossed deterministically regardless of how many extra time.time() calls the scheduler injects.
    • test_broadcast_latency_scales: the dispatch-overhead-only budget was 10 ms. Bumped to 30 ms to absorb py3.14 runner contention variance while still catching genuine regressions (the linear-scaling check in test_presence_list_scales_linearly still catches algorithmic O(n) regressions; this test only covers constant-time dispatch overhead). Observed 12× over-budget on py3.14 in PR #990 CI; cleanly under 30 ms on every other recorded run.

    No new dependencies; both fixes are pure test-code changes.

[0.7.3rc1] - 2026-04-25

Changed

  • djust_theming.W001 contrast-checks the active preset only by default (v0.7.3, #1005)check_preset_contrast previously iterated get_registry().list_presets().items() and ran WCAG AA contrast checks on every registered preset × mode × token pair. With djust's 65+ built-in presets, that produced hundreds of warnings on every manage.py check / pod start (in one observed project: 491 issues → ~480 W001 noise + ~11 real). The S/N ratio was bad enough that the warnings got ignored, which is the opposite of what you want from a check. Fix: new _contrast_check_scope() helper reads DJUST_THEMING.contrast_check_scope (default: "active") and the active scope iterates only the preset configured in LIVEVIEW_CONFIG.theme.preset — same setting check_preset_valid reads. Theme-pack authors who want the full exhaustive sweep opt in via:

    DJUST_THEMING = {"contrast_check_scope": "all"}
    

    Unknown values fall back to "active" (signal-preserving). When the configured preset is missing from the registry, the check yields zero warnings — check_preset_valid already fires E002 for that misconfiguration, so we don't double-warn. Behavior change for existing users: dropping into the "active" default silences hundreds of warnings about presets the project never uses; real W001 hits on the active preset still surface as before. Covered by 4 new regression tests (active-only default, opt-in all-scope, missing-active-preset edge case, unknown-scope-value fallback) plus 6 existing tests updated to opt into the exhaustive scope (they exercise the loop body, not the scope selector).

Fixed

  • djust.A070 no longer false-positives on {% verbatim %}-wrapped dj_activity examples (v0.7.3, #1004) — the A070 / A071 scanner walks template source as raw text. Templates that document the {% dj_activity %} tag — common pattern on docs / marketing pages that include literal example markup wrapped in {% verbatim %} so Django renders the example as-is — got flagged as real uninstrumented activity calls. Fix: new _strip_verbatim_blocks(content) helper redacts the BODY of every {% verbatim %}...{% endverbatim %} region (both unnamed and Django's named-form {% verbatim foo %}...{% endverbatim foo %}) before the regex scan. Newlines inside the region are preserved so line numbers from match.start() stay accurate for matches OUTSIDE the region. The scanner's existing iteration over _DJ_ACTIVITY_TAG_RE runs against the redacted source. Real uninstrumented {% dj_activity %} calls outside any verbatim block continue to fire A070 unchanged. Covered by 12 regression tests in python/tests/test_a070_verbatim_fp_1004.py (7 helper-contract tests + 5 scanner-integration tests including the canonical docs case, mixed verbatim + real calls, named verbatim form, and line number preservation).
  • djust.C011 now catches stale/placeholder output.css, not just totally-missing files (v0.7.3, #1003)_check_missing_compiled_css in python/djust/checks.py previously tested only os.path.exists(). A committed-but-stale output.css (e.g. a placeholder /* Run tailwindcss ... */) silently passed the check, the site rendered without any Tailwind utilities, and manage.py check emitted no warning. Reported by the docs.djust.org team after hitting it at launch — fresh-clone + make dev produced a broken page with zero warnings. Fix: new helper _output_css_looks_built(path) extends the contract to "the file exists AND looks built" — checks size > 10 KB AND a marker (tailwindcss banner OR @layer directive) in the first 512 bytes. The existing os.path.exists() branch is replaced with the helper. Both checks must pass; a 50 KB hand-rolled stylesheet without Tailwind markers is correctly flagged. Warning message updated from "output.css not found" to "output.css is missing or stale" with a hint that placeholder files are the canonical failure mode. Covered by 5 new regression tests (placeholder /* Run tailwindcss... */, empty 0-byte file, sub-10 KB file with banner, real built >10 KB Tailwind output, hand-rolled @layer stylesheet) plus 3 existing tests updated to use realistic Tailwind output (~16 KB minified-style fixture instead of the 18-byte placeholder that exposed the original bug).

[0.7.2rc1] - 2026-04-24

Added

  • Inline radio buttons via data-dj-inline attribute (v0.7.2, #991) — opt-in horizontal layout for forms.RadioSelect fields without writing any new Python. Users add widget=forms.RadioSelect(attrs={"data-dj-inline": "true"}) to a ChoiceField and load {% static 'djust/djust-forms.css' %} once in their base template; the bundled stylesheet uses the CSS :has() parent selector (Selectors Level 4 — Chromium 105+, Safari 15.4+, Firefox 121+, all stable since 2023) to walk up from each marked <input type="radio"> and lay out its containing wrapper as inline-flex with sensible spacing, full keyboard navigation, and the browser's native focus ring preserved. Composes with anything that renders a Django RadioSelect (plain forms.Form, LiveViewForm, ModelForms, Django admin, djust-theming form templates) — the same [data-dj-inline] selector targets both the stock <ul><li> markup and djust-theming's <div>-wrapped variant. Skip-able: don't link the CSS file → the attribute is inert. Override-able: write your own CSS rule keyed on [data-dj-inline] for any visual treatment (segmented controls, CSS Grid columns, etc.). New file: python/djust/static/djust/djust-forms.css. Documented in a new "Inline Radio Buttons" section of docs/website/guides/forms.md with the API, the why-data-attribute reasoning, and examples for customizing the visual treatment + multi-field forms. Covered by 12 regression tests in tests/test_inline_radios_991.py (3 Django-render contract tests + 5 CSS-ships-and-targets-correctly tests + 2 backwards-compat tests + 2 edge cases).

Decisions

  • ADR-012: _FRAMEWORK_INTERNAL_ATTRS filter is the right tool; do NOT rename framework-internal attrs (v0.7.2, #962, close-without-code) — v0.5.7 #762 added a _FRAMEWORK_INTERNAL_ATTRS frozenset in python/djust/live_view.py to prevent ~25 framework-set attrs (sync_safe, login_required, template_name, ...) from leaking into get_state() / reactive-state debug payloads. The v0.5.7 retro filed #962 to decide whether to additionally rename those attrs to _*-prefixed form as defense-in-depth. Decision after a full review: keep the filter, don't rename. Rename would break every user view reading self.login_required / self.template_name (both first-class documented attrs; the latter is Django public API) without net defense-in-depth benefit — the filter is a single centralized gate at the exact leakage point. Mitigation for the filter's maintenance burden: the PR review checklist will remind authors to add new framework-set attrs to the frozenset at introduction time. See docs/adr/012-framework-internal-attrs-filter-vs-rename.md.

Infrastructure

  • Weekly real-cloud CI matrix for upload writers (v0.7.2, #963) — all v0.5.7 upload-writer tests mock the SDKs. Happy-path end-to-end verification against real AWS S3 / Google Cloud Storage / Azure Blob was missing; silent regressions in credential handling, SDK auth chain changes, or bucket permissions could reach production without detection. New workflow .github/workflows/weekly-cloud-uploads.yml runs every Monday at 06:00 UTC (plus manual workflow_dispatch) against all three providers in parallel (fail-fast: false — each provider's outage is independent). Each matrix slot uploads a 1 MB blob, HEADs it, GETs it, and DELETEs it. Failure opens a tech-debt + new cloud-integration label issue via actions/github-script@v7 with a diagnostic link to the run. Credentials come from GitHub encrypted secrets (CLOUD_INT_AWS_*, CLOUD_INT_GCP_*, CLOUD_INT_AZURE_*) so contributors' PRs never have access. The three provider-specific integration tests live under tests/cloud_integration/ and auto-skip when DJUST_CLOUD_INTEGRATION isn't set — running the full test suite locally or in PR CI costs nothing. Cost: a few cents per provider per weekly run.

Documentation

  • key_template UUID-prefix convention for s3_events (v0.7.2, #964)djust.contrib.uploads.s3_events.parse_s3_event extracts upload_id by finding the first UUID-shaped path segment in the S3 object key; apps whose key_template doesn't produce such a segment silently fall back to the full key as upload_id, and hooks registered against the UUID then don't fire. This was the #1 source of "my hook isn't being called" reports from v0.5.7+ users. Fix: (a) the module docstring now documents the convention prominently with two recommended key_template shapes (uploads/<uuid>/<filename> and <tenant>/<uuid>/<filename>); (b) a DEBUG log entry fires on the djust.contrib.uploads.s3_events logger whenever fallback happens, naming the offending key — so enabling DEBUG logging once is enough to diagnose a silent hook; (c) a "Key-template convention for s3_events" section has been added to docs/website/guides/uploads.md with a debugging recipe and a pointer to the "custom upload-id routing" escape hatch (via x-amz-meta-upload-id / JWT / DB lookup). Covered by 3 new regression tests in tests/test_presigned_s3_820.py (no-UUID fallback + DEBUG log, happy path emits no log, UUID segment position doesn't matter).

Fixed

  • Rust renderer honors __str__ key on serialized model dicts (v0.7.2, #968)djust.serialization._serialize_model_safely sets "__str__": str(obj) on every dict it produces so {{ obj }} in a Rust-engine template can match Django's default str(obj) semantics. The Rust Value::Object Display impl (crates/djust_core/src/lib.rs) previously ignored the key and emitted the literal "[Object]" for any dict. This broke FK display silently in LiveView templates — {{ claim.claimant }} (where claimant serializes to a nested dict) rendered as [Object] instead of the claimant's string representation, since the page still returned 200 the only way to notice was visual inspection. Reported by a downstream consumer prototype team who hit six occurrences in a single project. Fix: when the value is Value::Object and contains a "__str__": Value::String(...) entry, render the string. Non-model dicts (no __str__, or __str__ not a string) keep the existing "[Object]" fallback. Plain Python objects with custom __str__ were already correct (handled by FromPyObject). Covered by 5 Rust unit tests in crates/djust_core/src/lib.rs::tests and 13 Python integration tests in tests/test_rust_renderer_str_key.py (model dict, nested FK, HTML-auto-escape, dotted-access, plain-dict fallback, null/int __str__ edge cases, empty-string __str__, backwards-compat for plain Python objects + lists + scalars).
  • djust.dev_server NameError on module load when watchdog is not installed (v0.7.2, #994) — the try/except ImportError block at dev_server.py:13-19 sets WATCHDOG_AVAILABLE = False but the class statement class DjustFileChangeHandler(FileSystemEventHandler) on line 25 referenced the symbol unconditionally. When watchdog is absent, class definition time crashes with NameError: name 'FileSystemEventHandler' is not defined, which in turn breaks python manage.py check in any djust install without the [dev] extra (because djust.checks.check_hot_view_replacement imports WATCHDOG_AVAILABLE from djust.dev_server). Latent since at least v0.5.4rc1 — the pattern predates the v0.5.x refactor; only surfaces when an install omits watchdog. Fix: the except ImportError branch now defines stub FileSystemEventHandler, FileSystemEvent, and Observer classes purely to satisfy the class statements below at import time. HotReloadServer.start() already short-circuits on WATCHDOG_AVAILABLE = False, so the stubs are never instantiated in a running process. Covered by 3 regression tests in tests/test_dev_server_watchdog_missing.py that block watchdog via a sys.meta_path finder and verify (a) djust.dev_server imports cleanly, (b) HotReloadServer.start() no-ops with the documented warning, (c) djust.checks.check_hot_view_replacement's downstream import path survives.

[0.7.1rc1] - 2026-04-24

Added

  • FORCE_SCRIPT_NAME / sub-path mount support for the in-browser HTTP API client (v0.7.1, #987, closes Action Tracker #123) — new template tag {% djust_client_config %} in djust.templatetags.live_tags emits <meta name="djust-api-prefix" content="...">. The content is derived via Django's reverse() so it honors both FORCE_SCRIPT_NAME and any custom api_patterns(prefix=...) mount. The djust client reads this meta tag once at bootstrap (00-namespace.js) and exposes two helpers: window.djust.apiPrefix (resolved prefix string) and window.djust.apiUrl(path) (prefix + path joiner with slash normalization). djust.call() (48-server-functions.js) now routes through djust.apiUrl() — the last remaining hardcoded /djust/api/ reference in the client bundle is gone. Priority: explicit window.djust.apiPrefix > meta tag > compile-time default /djust/api/. Integrators mounting djust behind a reverse proxy prefix now only need to add {% load live_tags %}{% djust_client_config %} to their base template <head>; no JS patching required. Covered by 12 new tests (5 Python in test_client_config_tag.py, 6 JS in api_prefix.test.js, 1 regression in server_functions.test.js asserting djust.call honors the meta tag under a forced script prefix). Bundle size delta: +148 B gzipped (50030 → 50178 B). Docs: "Sub-path deploys" section added to docs/website/guides/server-functions.md and docs/website/guides/http-api.md. Follow-up issue #992 filed for the same class of bug in 03b-sse.js:44 (SSE fallback transport, v0.7.2 target).

[0.7.0rc1] - 2026-04-24

Added

  • Streaming Markdown {% djust_markdown %} (v0.7.0) — server-side Markdown renderer built on pulldown-cmark 0.12 with three safety guarantees wired in at the crate level: raw HTML in the source is escaped (Options::ENABLE_HTML is never set; because pulldown-cmark 0.12 still emits Event::Html / Event::InlineHtml when that flag is off, sanitise_event re-routes those events to Event::Text so the writer escapes them), javascript: / vbscript: / data: URL schemes in link/image destinations are rewritten to # (case-insensitive, leading-whitespace tolerant), and inputs larger than 10 MiB (per-call input cap, not a concurrency limiter) are returned as an escaped <pre class="djust-md-toobig"> block without invoking the parser. A provisional-line splitter renders a partially-typed trailing line as escaped text inside <p class="djust-md-provisional">, eliminating mid-token flicker for streaming LLM output. Exposed three ways: the {% djust_markdown expr [kwargs] %} tag (registered via the existing Rust tag-handler registry), the Python helper djust.render_markdown(src, **opts) returning a SafeString, and the PyO3 function djust._rust.render_markdown. Kwargs: provisional, tables, strikethrough, task_lists. Note on deviation from plan: autolinks was dropped from the public surface — pulldown-cmark 0.12 does not expose a GFM_AUTOLINK / ENABLE_AUTOLINK options flag, so plain-text URLs stay as text unless wrapped in explicit [text](url) syntax. Will be reconsidered when the upstream parser is bumped. Covered by 24 Rust tests (crates/djust_templates/src/markdown.rs, including regression cases for vbscript:, data:, mixed-case JavaScript:, leading-whitespace URLs, <iframe> escaping, image-src neutralisation, and the 10 MiB cap) and 14 Python tests (python/djust/tests/test_markdown.py + tests/unit/test_markdown_tag.py), plus 3 A090 system-check tests — 41 total (24 Rust + 14 Python/tag + 3 A090). Demo at /demos/markdown-stream/; full write-up in docs/website/guides/streaming-markdown.md.
  • Admin widgets & bulk-action progress (v0.7.0) — two additions to djust.admin_ext close the most-requested gaps in the alternative reactive admin:
    • DjustModelAdmin.change_form_widgets / change_list_widgets class attributes accept any list of LiveView subclasses; each is embedded via {% live_render %} on the matching admin page. Permission filtering honours permission_required on the widget class. See docs/website/guides/admin-widgets.md.
    • @admin_action_with_progress (in djust.admin_ext.progress) turns any DjustModelAdmin action into a background daemon thread and redirects the user to a BulkActionProgressWidget page at <admin>/djust-progress/<job_id>/. The page polls the job every 500 ms, re-renders the progress bar / message / log, and wires a Cancel button that atomically flips done and cancelled. Queryset is eagerly pinned to PKs before the thread starts (no lazy-eval foot-guns). Cancellation is cooperative — clicking Cancel flips progress.cancelled = True; the action body must periodically check if progress.cancelled: return to actually stop (Python cannot safely interrupt a running thread mid-statement).
    • Server-side permission enforcement@admin_action_with_progress(permissions=[...]) stamps allowed_permissions on the wrapped action; ModelListView.run_action now calls request.user.has_perms(allowed) before dispatching the action and raises PermissionDenied if the user lacks any declared perm. Closes the gap where has_*_permission returns True for any staff user.
    • Bounded server state: _JOBS is LRU-capped at _MAX_JOBS = 500 (oldest entries evicted on insert once the cap is reached), and Job.message / Job.error are individually truncated to _MAX_MESSAGE_CHARS = 4096 on each progress.update(...) call. Job.error is a generic user-facing string ("Action failed — see server logs for details"); the raw exception text lives only on the server-side Job._error_raw attribute and is always logged at ERROR level via logger.exception (logger name djust.admin_ext.progress).
    • New setting: DJUST_ASGI_WORKERS (default 1) — declares the number of ASGI workers in the deployment. Gates the A073 system check (fires only when DJUST_ASGI_WORKERS > 1) so single-worker development stays silent.
    • Defense-in-depth allowlist: DJUST_LIVE_RENDER_ALLOWED_MODULES (optional) restricts the dotted-path module prefixes that {% live_render %} will resolve — any widget slot path outside the allowlist raises TemplateSyntaxError at render time.
    • Two new system checks: djust.A072 (warning) fires if a non- LiveView class is registered in a widget slot; djust.A073 (info, gated on DJUST_ASGI_WORKERS > 1) fires at startup if any admin site hosts a @admin_action_with_progress-decorated action, noting the v0.7.0 single-worker _JOBS limitation and pointing at the v0.7.1 channel-layer follow-up.
    • 25 new tests: python/djust/tests/test_bulk_progress.py (12) + python/djust/tests/test_admin_widgets_per_page.py (13); +A072/A073 check tests in python/tests/test_checks.py.
  • {% dj_activity %} + ActivityMixin (v0.7.0) — React 19.2 <Activity> parity: pre-rendered hidden regions of a LiveView that preserve their local DOM state (form inputs, scroll, transient JS) across show/hide cycles. The new block tag {% dj_activity "name" visible=expr eager=expr %}...{% enddj_activity %} emits a wrapper <div> carrying data-djust-activity, data-djust-visible, and — when not visible — the HTML hidden attribute plus aria-hidden="true". The body is rendered unconditionally in every pass so local state isn't lost. ActivityMixin (composed into LiveView AFTER StickyChildRegistry, BEFORE View) provides the server-side API: set_activity_visible(name, visible), is_activity_visible(name), declarative eager_activities: frozenset class attr, and an internal FIFO deferred-event queue (cap 100, overridable via activity_event_queue_cap) drained by the WebSocket consumer after every handle_event / handle_info dispatch. Client runtime (python/djust/static/djust/src/49-activity.js) exposes window.djust.activityVisible(name) and dispatches a bubbling djust:activity-shown CustomEvent when a panel flips hidden → visible. The event-dispatch gate in 11-event-handler.js drops events whose trigger sits inside a hidden non-eager activity client-side (stamping _activity on all other events for server-side deferral). The VDOM patcher in 12-vdom-patch.js skips subtree patches targeting nodes inside a hidden non-eager activity so DOM state is preserved. Two new system checks: A070 (Warning — missing name argument) and A071 (Error — duplicate activity name within one template). See docs/website/guides/activity.md for the full guide + {% if %} / {% live_render %} / sticky / dj-prefetch comparison matrix. Demo at examples/demo_project/djust_demos/views/activity_demo.py.
  • Intent-Based Prefetch (dj-prefetch, v0.7.0) — hover- and touch-driven navigation prefetch that complements the existing service-worker-mediated hover prefetch. Links opting in with <a dj-prefetch href="..."> are prefetched after a 65 ms hover debounce (cancelled on mouseleave before the debounce fires) and immediately on touchstart — mobile users commit to a tap fast, so no debounce is applied there. Prefetch uses <link rel="prefetch" as="document"> injection so the browser manages the cache lifecycle (falls back to low-priority fetch + AbortController when relList doesn't advertise 'prefetch'). Same-origin only; javascript: / data: URLs blocked; dedup'd per URL via a Set that window.djust._prefetch.clear() wipes on SPA navigation. Opt out per-link with dj-prefetch="false". Respects navigator.connection.saveData. New client surface: window.djust._intentPrefetch for test/diagnostic access. Scope: client-side only — no new server endpoint. Contract: dj-prefetch is intended for author-controlled navigation links only; don't put it on links that perform state-changing GETs (see the module header in python/djust/static/djust/src/22-prefetch.js for the full safety contract). See docs/website/guides/prefetch.md for the guide and the SW-hover-vs-intent comparison table.
  • Server Functions (@server_function / djust.call(), v0.7.0) — same-origin browser RPC without VDOM re-render. Decorate a LiveView method with @server_function and invoke it from JavaScript as await djust.call('<view_slug>', '<fn>', {params}); the return value is JSON-serialized straight back to the caller. The three primitives now split cleanly by intent:
    • @event_handler — WebSocket, triggers a VDOM re-render (UI interactions: click, submit, input).
    • @event_handler(expose_api=True) — HTTP (ADR-008), triggers a re-render AND exposes the handler to mobile / S2S / AI-agent callers via OpenAPI.
    • @server_function — HTTP, no re-render, no OpenAPI, no api_response / serialize= hooks. Designed exclusively for in-browser RPC; response envelope is the minimal {"result": <value>}. Session-cookie auth + CSRF are both required unconditionally — no auth-class opt-out. Request body shape is strict: only an empty body, {}, or {"params": {...}} are accepted; any other shape (flat objects, wrapped objects with sibling keys) returns 400 invalid_body. This deliberately removes the ambiguity where a caller's own field named params would be silently unwrapped and every sibling key dropped. The dispatcher reuses the ADR-008 pipeline unchanged: parameter coercion via validate_handler_params, @permission_required gating via check_handler_permission, and @rate_limit via the same LRU-capped _rate_buckets OrderedDict. Both sync and async def functions are supported via _call_possibly_async. Stacking @event_handler and @server_function on the same method raises TypeError at decoration time — a function either re-renders the view or returns an RPC result, never both. New URL: POST /djust/api/call/<view_slug>/ <function_name>/, declared BEFORE the catch-all dispatch pattern so it can't be shadowed. New public surface: djust.decorators.server_function, is_server_function, djust.api.DjustServerFunctionView, dispatch_server_function (in python/djust/api/dispatch.py), iter_server_functions. New client module python/djust/static/djust/src/48-server-functions.js (~40 LOC, ~430 B gzipped delta). Demo: examples/demo_project/djust_demos/ adds a product-search view demonstrating both features end-to-end. See docs/website/guides/server-functions.md for the full API reference, error-code table, and comparison vs. @event_handler and @event_handler(expose_api=True).

[0.6.1rc1] - 2026-04-24

Added

  • Time-Travel Debugging (v0.6.1) — dev-only debug-panel tab that records a state snapshot around every @event_handler dispatch (state_before / state_after), then lets developers scrub back through the timeline and jump to any past state. The server restores the snapshot via safe_setattr and re-renders through the normal VDOM patch pipeline. Opt-in per view (time_travel_enabled = True on the LiveView subclass); zero cost when disabled. Gated on DEBUG=True at the WebSocket consumer so production clients can't coerce a jump even if the class attr is left on. Per-view bounded ring buffer (default 100 events, configurable via LIVEVIEW_CONFIG["time_travel_max_events"]). New module djust.time_travel (EventSnapshot, TimeTravelBuffer, record_event_start, record_event_end, restore_snapshot). New inbound WS frame time_travel_jump + outbound time_travel_state ack, plus time_travel_event frames pushed after every recorded snapshot so the debug panel timeline populates incrementally (client CustomEvent djust:time-travel-event). Instrumentation wraps all three dispatch branches (actor, component, view handler) and records permission-denied / validation-failed events with an error marker. Component events record against the parent view in Phase 1 (full component-level time travel is a v0.6.2 follow-up). Ghost-attr cleanup in restore_snapshot removes public attributes not present in the target snapshot, so restoring {a:1} over {a:5, b:10} leaves {a:1} rather than {a:1, b:10}. New client events djust:time-travel-state and djust:time-travel-event (CustomEvents). New system checks djust.C501 (info — global switch on) and djust.C502 (error — non-positive time_travel_max_events). Beyond Redux DevTools: server-side so no client state store; beyond Phoenix LiveView's debug tools which are telemetry-only. See docs/website/guides/time-travel-debugging.md.

  • Streaming Initial Render (v0.6.1, Phase 1) — opt-in chunked HTTP response for LiveView GET requests. Setting streaming_render = True on a LiveView class returns a StreamingHttpResponse that flushes the page in three chunks: shell-open (everything before <div dj-root>), main content (the <div dj-root>...</div> body), and shell-close (</body></html> + trailing markup). Phase 1 is transport-layer only — the server fully assembles the rendered HTML before streaming it; the benefit is HTTP/1.1 chunked transfer (no Content-Length, earlier TCP flush, compatibility with chunk-relaying proxies, avoiding gzip-buffer stalls). True server-side render overlap (browser parses shell while server computes main content) arrives with Phase 2 (v0.6.2) alongside lazy-child streaming via {% live_render lazy=True %}. No client-side code changes; opt-in per view, backward-compatible default. Response emits X-Djust-Streaming: 1 for observability and omits Content-Length. See docs/website/guides/streaming-render.md.

  • Hot View Replacement (HVR, v0.6.1) — state-preserving Python code reload in development. When a LiveView module changes on disk, the dev server importlib.reload()s the module and swaps __class__ in place on every live instance of the changed class, then re-renders via the existing VDOM diff path. Users keep form input, counter values, active tab, and scroll position — React Fast Refresh parity for djust. Gated on DEBUG=True + LIVEVIEW_CONFIG["hvr_enabled"] (default True). Falls back to full reload on a conservative state-compat heuristic (removed handlers, changed handler signatures, or slot layout drift). New system check djust.C401 warns when HVR is enabled but watchdog is not installed. New client event djust:hvr-applied (CustomEvent). Zero cost in production.

    See docs/website/guides/hot-view-replacement.md.

[0.6.0rc1] - 2026-04-23

Documentation

  • CSS @starting-style guide section (v0.6.0) — documents that browser-native @starting-style works unmodified with djust's VDOM insert path. No new djust attributes or JS — the feature is pure CSS. Guide section in docs/website/guides/declarative-ux-attrs.md includes a quick-start example, a side-by-side comparison vs dj-transition (browser support, runtime cost, per-element customization), interop notes with dj-remove for enter+exit coverage, and caveats around @supports gating for older browsers. ROADMAP parity-tracker row updated to ✅ Documented v0.6.0.

Changed

  • Package consolidation sunset — ADR-007 Phase 4 closure (v0.6.0) — the three-phase consolidation that started in v0.5.0 is now complete. The five sibling repos (djust-auth, djust-tenants, djust-theming, djust-components, djust-admin) are sunset at v99.0.0 — each retains a shim-only __init__.py that re-exports from djust.<name> and emits a DeprecationWarning. Path A was chosen over PyPI publish: existing releases remain installable indefinitely for legacy projects; no new PyPI versions will ship. djust core now exposes the consolidation via [project.optional-dependencies]djust[auth], djust[tenants] (with djust[tenants-redis] and djust[tenants-postgres] backend-specific sub-extras), djust[theming], djust[components], djust[admin]. Two new extras (auth, tenants) added in this release; the others shipped in v0.5.0. ADR-007 status updated from "Proposed" → "Accepted + Phase 4 complete". New migration guide: docs/website/guides/migration-from-standalone-packages.md (mechanical sed script + FAQ + edge cases). Cosmetic tech-debt: sibling repos retain dead pre-consolidation source files next to the shim — cleanup tracked separately; no user impact.

Added

  • Request-path profiling harness (v0.6.0, investigative, ROADMAP Group 5 P2) — reproducible profile of the mount → event → VDOM diff → patch path. New scripts/profile-request-path.py (cProfile wrapper, optional py-spy hint, writes artifacts/profile-<timestamp>.{txt,pstats}; exits non-zero on target-miss for CI). New tests/benchmarks/test_request_path.py with eight pytest-benchmark cases across four groups (HTTP render, WebSocket mount, event dispatch, VDOM diff+patch) with hard assertions against the 2 ms per-event / 5 ms list-update budgets. New docs/performance/v0.6.0-profile.md reporting all measured timings (mount 0.07 ms, event 4 µs, VDOM diff 4 µs, list reorder 0.38 ms — all within targets by at least 5x). New make profile target wired to the harness (the prior make profile runtime-stats target is now make profile-stats). No optimizations were required; the profile confirms the existing Rust-side architecture is well under target.

  • Service Worker advanced features (v0.6.0) — three SW-backed optimizations landed in one PR:

    • VDOM patch cache: per-URL HTML snapshots served instantly on popstate, then reconciled against the live WebSocket mount reply. Configurable via DJUST_VDOM_CACHE_ENABLED / DJUST_VDOM_CACHE_TTL_SECONDS / DJUST_VDOM_CACHE_MAX_ENTRIES. New system checks djust.C301 / C302 / C303 guard config ranges.
    • LiveView state snapshots: opt-in per view via enable_state_snapshot = True on a LiveView subclass. Client captures JSON-serializable public state on djust:before-navigate; server restores via _restore_snapshot(state) in lieu of mount() when the user hits back. Views override _should_restore_snapshot(request) to reject stale snapshots. System check djust.C304 warns when a snapshot-opt-in view declares attributes matching PII naming patterns.
    • Mount batching: when multiple dj-lazy LiveViews hydrate together, the client sends one mount_batch WebSocket frame instead of N separate mount frames. Server responds with one mount_batch carrying all rendered views; per-view failures are isolated in a failed[] array (atomicity relaxed so one bad view doesn't kill the batch). Opt out via window.DJUST_USE_MOUNT_BATCH = false.
    • New client module 46-state-snapshot.js (~120 LOC); new senders on djust._sw.cacheVdom/lookupVdom/captureState/lookupState.
    • registerServiceWorker({vdomCache: true, stateSnapshot: true}) gates the new behaviors alongside existing instantShell / reconnectionBridge options.

    See docs/website/guides/service-worker.md.

Changed

  • LiveViewConsumer.handle_mount() accepts new state_snapshot kwarg; dispatches to the snapshot-restore path when the view opts in and the payload's view_slug matches. New method handle_mount_batch() + _mount_one() collector seam enable the mount-batch path without regressing the single-view mount flow.

Security

  • State snapshots are JSON-only (no pickle). safe_setattr blocks dunder keys and private (_-prefixed) attributes during restoration. SW enforces a 256 KB upper bound on state_json payloads; client clamps at 64 KB. System check djust.C304 warns when snapshot-opt-in views declare attribute names matching password|token|secret|api_key|pii.

  • Sticky LiveViews (v0.6.0) — Phoenix live_render sticky: true parity. Shipped across three PRs: #966 (Phase A — embedding primitive), #967 (Phase B — preservation across live_redirect), #969 (Phase C — ADR-011, user guide, demo app). Mark a LiveView class with sticky = True + sticky_id and embed it via {% live_render "myapp.views.AudioPlayerView" sticky=True %}. Destination layouts declare <div dj-sticky-slot="<id>"></div> at the re-attachment point; the same Python instance, DOM subtree, form values, scroll/focus, and background tasks all survive live_redirect navigation. Use case: app-shell widgets (audio players, sidebars, notification centers), wizard preview panes.

    User-facing API

    • LiveView.sticky: bool = False + sticky_id: Optional[str] = None class attrs.
    • {% live_render "dotted.path" sticky=True %} template tag (validates class opt-in at render time; TemplateSyntaxError on mismatch).
    • [dj-sticky-slot="<id>"] slot markers in destination layouts.
    • djust:sticky-preserved / djust:sticky-unmounted CustomEvents for lifecycle hooks (reasons: server-unmount, no-slot, auth).
    • _on_sticky_unmount() per-instance hook (default: cancels pending start_async tasks).

    Wire protocol

    • child_update (Phase A) — scoped VDOM patches for embedded non-sticky children.
    • sticky_hold (server→client, sent BEFORE mount on live_redirect) — enumerates surviving sticky_ids so the client reconciles its stash against the authoritative list. Ordering is load-bearing: the mount handler eagerly reattaches, so a late sticky_hold would reattach auth-revoked views.
    • sticky_update (server→client) — per-child VDOM patches scoped to [dj-sticky-view="<id>"] via a new applyPatches(patches, rootEl) variant in 12-vdom-patch.js (when rootEl is non-null, node lookups / focus save-restore / autofocus queries all scope to that subtree).
    • Per-view VDOM version tracking via clientVdomVersions: Map<view_id, number> with "__root" sentinel for top-level patches.

    Client-side

    • static/djust/src/45-child-view.jsstickyStash Map; stashStickySubtrees() (detach on outbound nav), reconcileStickyHold(views) (drop non-authoritative), reattachStickyAfterMount() (replace [dj-sticky-slot] with stashed subtree via replaceWith() — DOM identity preserved), handleStickyUpdate(msg) (scoped patch apply), clearStash() (abnormal-close cleanup).
    • 18-navigation.js calls stashStickySubtrees() BEFORE outbound live_redirect_mount (and before popstate-triggered redirects).
    • 03-websocket.js onclose calls clearStash() on abnormal disconnect.
    • [dj-root] audit across 40-dj-layout.js, 24-page-loading.js, 12-vdom-patch.js autofocus sites adds :not([dj-sticky-root]) so sticky children don't masquerade as layout / page roots.

    Security

    • Per-sticky auth re-check via new djust.auth.check_view_auth_lightweight(view, request) -> bool; a sticky view whose permissions are revoked mid-session is unmounted on the next navigation.
    • DJUST_LIVE_RENDER_ALLOWED_MODULES prefix-allowlist gates dotted-path resolution (unset = permit-all, backward compatible).
    • sticky_id HTML-escaped via server-side escape() + CSS.escape on client-side selectors.
    • Client stash bounded by developer-authored content; idempotent stashStickySubtrees coalesces duplicates; cleared on abnormal WS close.
    • Inbound sticky_update / sticky_hold frames rejected by the consumer's allowlist (server-to-client only).

    Testing (32 Python + 20 JSDOM + 6 integration)

    • 11 Phase A tests in tests/unit/test_live_render_tag.py (HTML-parsed) + 21 Phase B/C tests in tests/unit/test_sticky_preserve.py.
    • 7 Phase A tests in tests/js/child_view.test.js + 15 Phase B/C tests in tests/js/sticky_preserve.test.js.
    • 3 end-to-end tests in tests/integration/test_sticky_redirect_flow.py (Dashboard→Settings preservation, rapid A→B→A instance identity, no-slot reconcile path) + 3 demo-app smoke tests covering the full navigation cycle.
    • Phase C regression tests: skipMountHtml mount branch reattaches sticky subtrees (Fix F1); disconnect() drains _sticky_preserved so background tasks don't leak (Fix F2).

    Documentation

    • ADR-011 — wire protocol, DOM attributes, client/server flow diagrams, full security model + threat matrix, failure modes, relationship to v0.7.0 dj-activity.
    • User guide — quick start, common patterns, limitations, debugging, FAQ.
    • Runnable demo app in examples/demo_project/sticky_demo/ — Dashboard, Settings, Reports pages with sticky AudioPlayer + NotificationCenter widgets showing preservation + no-slot unmount.
  • FLIP list-reorder animations (v0.6.0 animations milestone finale) — Opt-in per container via dj-flip. Declarative attribute on a list parent animates direct-child reorders using First-Last-Invert-Play. Tunables: dj-flip-duration (default 300ms, parsed via Number + isFinite + clamp [0, 30000] — trailing garbage rejects to fallback), dj-flip-easing (default cubic-bezier(.2,.8,.2,1), strings containing ;"'<> rejected to defeat CSS-property-breakout). Respects prefers-reduced-motion. Nested [dj-flip] isolated via subtree: false. Author-specified inline transform on children is preserved across the animation. Overlapping reorders are guarded against cache corruption via an in-flight-transition check. Works with keyed lists where items carry stable id= (Rust VDOM emits MoveChild). Lands in static/djust/src/44-dj-flip.js (~260 LOC). 12 JSDOM tests in tests/js/dj_flip.test.js.

  • {% djust_skeleton %} shimmer placeholder (v0.6.0 animations milestone finale) — Template tag for placeholder blocks. Props: shape (line|circle|rect, whitelist-validated), width/height (regex-whitelisted against ^[\d.]+(px|em|rem|%|vh|vw|ch)?$, invalid falls back to shape default), count (clamped to [1, 100]), class_. All values HTML-escaped via build_tag(). Shimmer @keyframes emitted once per render via context.render_context. Integrates with existing dj-loading shorthand and with {% if async_pending %} server blocks. 21 Python tests in tests/unit/test_djust_skeleton_tag.py.

[0.5.7rc1] - 2026-04-23

Added

  • Resumable uploads across WebSocket disconnects (v0.5.7 — closes #821) — Long mobile uploads now survive network hiccups, backgrounded tabs, and brief WS drops. New djust.uploads.resumable.ResumableUploadWriter wraps any existing UploadWriter (S3 MPU, GCS, Azure, tempfile) and persists chunk-level state into a pluggable UploadStateStore. Two stores ship in core: InMemoryUploadState (default, single-process) and RedisUploadState (requires djust[redis], multi-process / multi-host). New WS message {"type":"upload_resume","ref":X} returns {"type":"upload_resumed","status":"resumed|not_found|locked","bytes_received":N,"chunks_received":[...]}. New HTTP status endpoint GET /djust/uploads/<upload_id>/status (session-scoped, cross-user probes blocked). Client-side IndexedDB cache in 15-uploads.js lets tabs resume uploads after reload if the file reference can be re-selected. State is capped at 16 KB per upload_id (run-length-compressed chunk ranges) with 24-hour default TTL. Opt-in per slot: allow_upload("video", writer=S3Resumable, resumable=True). ~1,050 LOC net across python/djust/uploads/ (__init__.py modified, resumable.py, storage.py, views.py added), python/djust/websocket.py, python/djust/static/djust/src/15-uploads.js (+ 03-websocket.js dispatch), full wire-protocol spec + failure-mode + security analysis in docs/adr/010-resumable-uploads.md. 44 unit tests in python/djust/tests/test_resumable_uploads_821.py (compaction, in-memory + fake-Redis roundtrip, writer lifecycle, resume resolution, TTL expiry via mock clock, concurrent-resume rejection, HTTP status view) plus 2 async WS handler cases in the same file, plus 9 JSDOM cases in tests/js/upload_resume.test.js (file-hint fingerprint, UUID round-trip, IDB shim roundtrip, cleanup on complete).

  • Upload writers — S3 pre-signed PUT URLs + first-class GCS/Azure backends (v0.5.7 — closes #820, #822) — New djust.contrib.uploads.s3_presigned module lets clients upload directly to S3 via a pre-signed URL; djust only signs and observes completion via S3 event webhook. New djust.contrib.uploads.gcs.GCSMultipartWriter and djust.contrib.uploads.azure.AzureBlockBlobWriter ship as first-class UploadWriter subclasses with consistent error taxonomy (UploadError, UploadNetworkError, UploadCredentialError, UploadQuotaError, re-exported from djust.uploads). Client-side djust.uploads.uploadPresigned(spec, file, hooks) streams bytes straight to object storage via XHR (progress via xhr.upload.onprogress), bypassing the WS upload machinery. Optional extras: djust[s3], djust[gcs], djust[azure]. ~650 LOC + 50 regression tests (mocked SDKs) across python/djust/tests/test_presigned_s3_820.py, python/djust/tests/test_gcs_upload_writer_822.py, python/djust/tests/test_azure_upload_writer_822.py. See docs/website/guides/uploads.md.

  • Docs cleanup: 4 issues closed — dj-remove no-CSS-transition gotcha (#902), dj-transition-group long-form precedence (#907), Django 5.1 + 5.2 classifiers in pyproject.toml (#912), new guide page for dj-virtual variable-height mode at docs/website/guides/virtual-lists.md (#952).

  • dj-virtual variable-height items via ResizeObserver — closes #797 — PR #796 shipped dj-virtual with fixed-height items only. This adds opt-in variable-height support via a new dj-virtual-variable-height boolean attribute. Implementation: ResizeObserver per rendered item feeds a Map<index, number> height cache; a lazily-computed prefix-sum array drives offset math and the virtual spacer total. Unmeasured items fall back to a configurable dj-virtual-estimated-height (default 50px). Fixed-height mode (dj-virtual-item-height="N") is unchanged — tested explicitly as a regression guard. Updated 29-virtual-list.js (~180 LOC net) and 4 new JSDOM cases in tests/js/virtual_list.test.js covering attribute activation, mixed-height prefix-sum math, RO-driven cache updates, and fixed-mode regression.

  • Tooling: CHANGELOG test-count validator — closes #908 — new scripts/check-changelog-test-counts.py parses phrases like N JSDOM cases, N regression tests, N unit tests, N test cases, N parameterized cases in the [Unreleased] section, resolves every backticked tests/js/*.test.js / python/djust/tests/*.py / tests/unit/*.py path inside the same bullet, counts test functions in each, and fails if the claim doesn't match reality. Delta phrases (2 new cases, 3 additional tests) are deliberately skipped — they can't be verified without git history. Wired into .pre-commit-config.yaml as a local hook scoped to ^CHANGELOG\.md$ and exposed as make check-changelog. Self-tested by 7 cases in tests/test_changelog_test_counts.py covering match/mismatch, JSDOM-vs-py file resolution, multi-file summing, delta ignore, and missing-section tolerance.

  • Tooling: CodeQL triage script — closes #916scripts/codeql-triage.sh [rule-id] paginates /repos/{owner}/{repo}/code-scanning/alerts?state=open via gh api and emits a markdown triage doc grouped by rule.id, sorted within each group by file/line. Optional positional arg filters to a single rule for focused triage sessions. Turns the raw alert dump (noisy JSON) into something reviewable in a PR comment or a doc. Documented in scripts/README.md.

  • Tooling: CodeQL sanitizer MaD model — closes #934 — new extension pack at .github/codeql/models/ (qlpack.yml + djust-sanitizers.model.yml) teaches CodeQL that djust._log_utils.sanitize_for_log() is a log-injection sanitizer. Referenced from .github/codeql/codeql-config.yml via a new packs: section. Closes the class of false-positive py/log-injection alerts we've been dismissing individually. Verification lands with the next main-branch CodeQL scan. See .github/codeql/README.md for the tuple shape, fallback plan (hand-written LogInjectionFlowConfiguration override), and links to CodeQL's data-extensions docs.

  • ADR-009: Mixin side-effect replay on WebSocket state restoration — closes #897 — formalizes the _restore_<concept>() pattern first shipped ad-hoc in PRs #891 (UploadMixin, #889) and #895 (PresenceMixin

    • NotificationMixin, #893 / #894). Codifies the serialization contract (JSON-only saved attrs), error handling (WARNING-level wrap, never kill the WS), convergence/idempotency requirement, naming convention (_restore_<concept>), and call ordering in LiveViewConsumer. Documents the rejected alternatives: don't-skip-mount (perf cost), snapshot-entire-managers (serialization complexity), pickle-to-session (security + format stability). New file: docs/adr/009-mixin-side-effect-replay.md.

Fixed

  • Framework cleanup (closes #762, #890) — djust.A010 / A011 system checks now recognize proxy-trusted deployments: when SECURE_PROXY_SSL_HEADER + DJUST_TRUSTED_PROXIES are both set, ALLOWED_HOSTS=['*'] is accepted (supports AWS ALB, Cloudflare, Fly.io, and other L7 load balancers where task private IPs rotate). Also filters ~25 framework-internal attrs (sync_safe, login_required, template_name, http_method_names, on_mount_count, page_meta, etc.) from LiveView.get_state(), the WS _snapshot_assigns change-detection path, and the _debug.state_sizes observability payload — user's reactive state is no longer swamped by framework config. Non-breaking fix via a new live_view._FRAMEWORK_INTERNAL_ATTRS frozenset; attribute names unchanged. 14 new regression tests in python/djust/tests/test_a010_proxy_trusted_890.py and python/djust/tests/test_get_state_filter_762.py. Deployment guide updated with the proxy-trusted escape-hatch pattern.

  • JS-centric batch (closes #949, #951, #953) — tag_input hidden-input payload now JSON-encoded instead of comma-separated, so tag values containing commas round-trip intact (#949). dj-virtual variable-height cache now keyed by data-key attribute (configurable via dj-virtual-key-attr), falling back to index when absent — cached heights survive item reorders (#951). Consolidated JSDOM test helpers at tests/js/_helpers.js (createDom, nextFrame, fireDomContentLoaded, makeMessageEvent, mountAndWait) and refactored 3 test files to use them (#953). 2 new Python regression tests (commas + quotes round-trip) and 3 new JSDOM cases (reorder survival, index fallback, custom key attribute). Guardrail added to scripts/build-client.sh to fail fast if tests/js/_helpers.js ever leaks into the production bundle.

  • Hygiene batch (closes #791, #794, #795, #818, #948) — bumped ruff-pre-commit from v0.8.4 to v0.15.11 (#948) and applied ruff format to all resulting drift (#791 — expanded beyond the original 5 files due to modern-ruff disagreements; 19 files total across python/djust/ and tests/). Added logger.debug notice in components/suspense.py when {% dj_suspense await=X %} receives a non-AsyncResult value so a typo surfaces during development (#794), simplified a redundant or not value.ok check near suspense.py:138 given the AsyncResult mutually-exclusive-flag invariant (#795), wrapped the namespaced data-hook attribute value with django.utils.html.escape() for defense-in-depth in templatetags/live_tags.py (#818), and corrected stale test-count claims in two historical CHANGELOG bullets (test_assign_async.py 11 → 18, test_suspense.py 11 → 12) flagged by the #795 reviewer. No behavior change.

  • Security + cleanup: pre-existing test failures, redirect audit, dep ceilings, edge tests — closes #910, #921, #922, #935#935: fixed 3 stale test assertions that were checking for leaked exception-class names in API error responses. The implementations in api/dispatch.py, observability/views.py deliberately sanitize error payloads (don't echo RuntimeError / internal method names to clients; send to server logs instead). Tests now verify the sanitized contract ("server logs" in error, handler_name / session_id echo) rather than the leaked details. Fixes test_api_response.py::test_dispatch_serialize_str_missing_method_returns_500, test_observability_eval_handler.py::test_eval_500_when_handler_raises, and test_observability_reset_view.py::test_reset_500_when_mount_raises. #921: expanded open-redirect audit beyond PR #920 — mixins/request.py now validates hook_redirect returned by developer-defined on_mount hooks via url_has_allowed_host_and_scheme, falling back to "/" and logging a WARNING on unsafe targets. auth/mixins.py LoginRequiredLiveViewMixin.dispatch now validates the computed login URL as defense-in-depth against misconfigured settings.LOGIN_URL, falling back to "/accounts/login/". #922: 7 new regression tests in python/djust/tests/test_security_redirects_paths.pyjavascript: scheme rejection, HTTPS-to-HTTP downgrade, null-byte path-injection, uppercase/case-sensitive allowlist, hook_redirect off-site rejection, hook_redirect same-site acceptance, and off-site LOGIN_URL fallback. #910: added upper-bound ceilings to all runtime + dev dependencies in pyproject.toml (e.g. requests>=2.28,<3, orjson>=3.11.6,<4, nh3>=0.2,<1). Prevents uncontrolled major bumps during uv lock refresh (see PR #909 which caught Django 6.x resolving under >=4.2). Ceiling policy documented in a comment above [project.dependencies]. Verified with uv lock — only material change is redis 7.3 -> 6.4 (stays under new <7 ceiling).

  • UploadMixin defensive replay for schema-changed configs — closes #892_restore_upload_configs now wraps each per-slot allow_upload(**cfg) in try/except TypeError. On signature mismatch (kwarg added / renamed / removed between djust versions), logs a WARNING identifying the slot

    • the mismatched kwarg, then falls back to allow_upload(slot_name) — bare-minimum replay — so uploads for that slot still work with default config. One broken saved dict no longer kills replay for every other slot on the page. Each saved dict is now tagged with _upload_configs_version = 1 for future explicit migrations. Regression tests in tests/unit/test_mixin_replay_schema_cross_loop_892_896.py.
  • NotificationMixin cross-loop restore — closes #896_restore_listen_channels now detects when the PostgresNotifyListener singleton is stranded on a closed event loop (server restart with fresh ASGI loop, test harness per-test loops, sticky-session LB cross-worker handoff) and calls a new PostgresNotifyListener.reset_for_new_loop() classmethod to drop the singleton before replay. The pre-check inspects listener._loop.is_closed(); a per-channel except RuntimeError branch handles the race where the loop closes between the pre-check and the ensure_listening call (resets and retries once). Prevents silent NOTIFY drops on cross-loop restore. Regression tests in tests/unit/test_mixin_replay_schema_cross_loop_892_896.py.

  • Observer JS — closes #879, #880, #881, #882#879: 37-dj-mutation.js and 38-dj-sticky-scroll.js document-level root observers now detect attribute REMOVAL on already-observed elements (via attributes: true + attributeFilter: ['dj-mutation'] / ['dj-sticky-scroll']) and call the module's teardown helper. Previously removing the attribute from an element left a stale MutationObserver + scroll listener attached. #880: documented the Map-vs-WeakMap choice in 39-dj-track-static.js — the reconnect-diff iterates all tracked elements to compare snapshot URLs, and WeakMap does not support iteration; the isConnected check in _checkStale handles detached elements. #881: documented unconditional scroll-to-bottom on install in 38-dj-sticky-scroll.js — matches Phoenix phx-auto-scroll / Ember scroll-into-view behavior (sticky-scroll is an "opt into bottom-pinning" attribute; authors want the initial view pinned to the most recent content: chat, log output). #882: regression test in tests/js/dj_mutation.test.js — no dj-mutation-fire CustomEvent fires when the element is removed before the debounce timer expires (existing _tearDownDjMutation path correctly clears the pending timer on removal).

Tests

  • dj-transition-group follow-ups — closes #905, #906#905 The VDOM RemoveChild integration test in tests/js/dj_transition_group.test.js waited 700 ms per run for the default dj-remove fallback timer. Pinned dj-remove-duration="50" on the child and reduced the wait to ~80 ms, dropping this file's wallclock from ~1.2 s to ~600 ms. #906 Added a nested-group regression test — outer + inner [dj-transition-group] parents each install their own per-parent observer (subtree:false), so a new child appended to inner gets the inner group's enter/leave specs and is not clobbered by the outer's. Pins the subtree-scoping invariant relied on by the phase-2c implementation.

Fixed

  • Mechanical cleanup — closes #914, #915#914: dropped redundant ch == " " clause in _log_utils.sanitize_for_log — ASCII space is already printable so the explicit check was dead. #915: bulk-applied ruff format (pinned pre-commit version 0.8.4) to 4 pre-drifted files (3 theming test files + uploads.py) to bring them to canonical form. No behavior change in either fix.

  • 3 latent bugs caught by prior CodeQL-cleanup audits — closes #930, #932, #933#930 FormArrayNode inner content: {% form_array %}...{% endform_array %} parsed the block body into a nodelist via parser.parse(("endform_array",)) but FormArrayNode.render never rendered that nodelist — users' inner template markup silently disappeared. Fixed by rendering the nodelist once per row with row, row_index, and forloop (dict shape: {counter, counter0, first, last}) pushed onto the template context; empty or whitespace-only blocks keep the original single-input-per-row default output, so existing users see no change. #932 tag_input missing name= attribute: TagInput._render_custom rendered a visible "type to add" <input class="tag-input-field" placeholder="..."> with no name=, so form submissions silently dropped the tag list from POST data. Fixed by emitting a <input type="hidden" name="<self.name>" value="<csv of tags>"> alongside the visible input whenever self.name is non-empty; hidden value is html.escape'd. #933 gallery/registry.py dead discover_* path: discover_template_tags() and discover_component_classes() were public helpers exported from djust.components.gallery.__init__ but get_gallery_data() never called them — a developer adding a new @register.tag or Component subclass without updating the curated EXAMPLES / CLASS_EXAMPLES dicts had that new thing silently missing from the rendered gallery. Fixed by wiring both helpers into get_gallery_data() as a cross-check: any registered tag / component class missing an example entry emits a logger.debug warning naming the missing entries, and discovery failures are caught so the gallery never breaks at runtime. 14 regression tests across python/djust/tests/test_form_array_930.py, python/djust/tests/test_tag_input_932.py, python/djust/tests/test_gallery_registry_933.py (7 of which fail on main pre-fix; 2 added later under #949 for commas-in-values round-trip). No behavior change for non-broken inputs. (python/djust/components/templatetags/djust_components.py, python/djust/components/components/tag_input.py, python/djust/components/gallery/registry.py)

  • dj-remove follow-ups — closes #900, #901 — Extracted shared _teardownState(el, state) helper in 42-dj-remove.js so _finalizeRemoval and _cancelRemoval no longer duplicate the clearTimeout + removeEventListener + observer.disconnect + _pendingRemovals.delete block (Stage 11 nit from PR #898). Added a debug warning (gated on globalThis.djustDebug) when _parseRemoveSpec encounters a 2-token value like dj-remove="fade-out 300" — previously silent fall-through. 2 new JSDOM regression cases in tests/js/dj_remove.test.js (12/12 passing).

  • dj-transition edge cases — closes #886, #887, #888#886 _parseSpec in 41-dj-transition.js now rejects comma, paren, and bracket separators up front (returns null and emits a debug warning gated on globalThis.djustDebug) instead of letting classList.add throw InvalidCharacterError at runtime — matches the dj-remove #901 loud-in-debug / silent-in-prod pattern. #887 The cleanup callback (both transitionend handler and 600 ms fallback path) now guards with el.isConnected — if the element has been detached from the DOM before cleanup fires, we skip classList and listener work and just drop the _djTransitionState entry. Prevents any future parentNode.X access from NPE'ing on a detached node. #888 Unskipped the two previously-flaky transitionend tests in tests/js/dj_transition.test.js by swapping timing-sensitive setTimeout(..., 30) waits for synchronous el.dispatchEvent(new Event('transitionend')) — deterministic under vitest parallel load. Added one new test covering the #886 parser rejection path. All 9 dj-transition tests pass deterministically.

[0.5.6rc1] - 2026-04-23

BREAKING CHANGES

  • Dropped Python 3.9 support (requires-python = ">=3.10"). Python 3.9 reached end-of-life on 2025-10-05; the ecosystem has since moved on (orjson, pytest, python-dotenv, requests, and mcp have all dropped py3.9 support in versions that carry security fixes). Keeping py3.9 in the requires-python constraint kept 4 Dependabot alerts stuck open against the py3.9 resolution train — alerts which had no upstream patch available on py3.9. Closes Dependabot alerts #41 (orjson recursion DoS), #87 (pytest tmpdir race), #89 (python-dotenv symlink follow in set_key), #62 (requests insecure temp file reuse). Existing py3.9 users can continue installing djust v0.5.x from PyPI; v0.5.6+ requires py3.10+. Also bumped [tool.ruff] target-version to py310 and [tool.mypy] python_version to 3.10; collapsed the orjson / mcp conditional pins (previously carried a py3.9-stuck floor).

Added

  • dj-remove — exit animations before element removal (v0.6.0) — Phoenix JS.hide / phx-remove parity. When a VDOM patch, morph loop, or dj-update prune would physically remove an element carrying dj-remove="...", djust delays the actual removeChild() until the CSS transition the attribute describes has played out (or a 600 ms fallback timer fires, overridable via dj-remove-duration="N"). Two forms: three-token dj-remove="opacity-100 transition-opacity-300 opacity-0" matches the dj-transition shape (start → active → end), and single-token dj-remove="fade-out" applies one class and waits for transitionend. If a subsequent patch strips the dj-remove attribute from a pending element, the pending removal cancels and the element stays mounted. Public hook window.djust.maybeDeferRemoval(node) is called from five removal sites in 12-vdom-patch.js. Descendants of a [dj-remove] element are NOT independently deferred — they travel with their parent, matching Phoenix. New static/djust/src/42-dj-remove.js. 10 JSDOM cases in tests/js/dj_remove.test.js. Phase 2a of the v0.6.0 Animations & transitions work; FLIP / dj-transition-group / skeletons remain separate follow-ups.

  • dj-transition-group — orchestrate enter/leave animations for child lists (v0.6.0) — React <TransitionGroup> / Vue <transition-group> parity. Authors mark a parent container and specify enter + leave specs once; djust wires those specs onto each child by setting dj-transition (enter) and dj-remove (leave) — re-using the already-shipped phase-1 / phase-2a runners (#885 / #898) rather than re-implementing the phase-cycling or removal-deferral machinery. Two forms: short dj-transition-group="fade-in | fade-out" (pipe-separated halves, each accepting the same 1- or 3-token shape as dj-transition / dj-remove), and long form with bare dj-transition-group plus dj-group-enter / dj-group-leave on the parent. Initial children get the leave spec only by default (so they animate out if later removed, but nothing animates in on first paint); opt them into first-paint enter animation via dj-group-appear on the parent. Never overwrites author-specified dj-transition or dj-remove on a child — escape hatch for per-item overrides. A per-parent MutationObserver picks up newly appended children; a document-level observer handles parents that arrive via VDOM patch or attribute mutation. New static/djust/src/43-dj-transition-group.js. 11 JSDOM cases in tests/js/dj_transition_group.test.js cover short-form parsing, invalid input, manual _handleChildAdded, respect for pre-existing per-child attrs, default leave-only initial wiring, dj-group-appear enter opt-in, post-mount append via observer, _uninstall disconnecting the per-parent observer, parent-removal auto-cleanup via the root observer, end-to-end VDOM RemoveChild deferral through the wired dj-remove, and cancel-on-strip uninstalling the per-parent observer when dj-transition-group is removed at runtime (symmetric with dj-remove). Phase 2c of the v0.6.0 Animations & transitions work; FLIP and skeletons remain separate follow-ups. (python/djust/static/djust/src/43-dj-transition-group.js)

Fixed

  • Code-scanning cleanup: remaining ~35 py/cyclic-import notes + 7 misc note-level alerts. Real refactor: extracted ContextProviderMixin from live_view.py to a new _context_provider.py module so components/base.py can import it without creating a module-level cycle back through live_view -> serialization -> components/base. live_view.py re-exports ContextProviderMixin for back-compat (existing user code importing from djust.live_view import ContextProviderMixin keeps working). Closes 3 real cyclic-import alerts (#2112, #2113, #2114). The remaining ~28 theming cyclic-import notes (in manager.py, registry.py, theme_css_generator.py, pack_css_generator.py, theme_packs.py, manifest.py, css_generator.py) are all from ... import statements INSIDE function bodies (or the module-level counterpart paired with such a lazy import) — deliberate cycle breakers where the runtime module graph is acyclic — dismissed with specific justification. Also fixed 3 py/mixed-returns via mechanical cleanup: theming/inspector.py (added 405 Method-Not-Allowed fallback), admin_ext/views.py (replaced bare return with return None in run_action), management/commands/djust_audit.py (explicit return None from all handle() branches). Dismissed 3 py/unused-global-variable false positives (lazy-init cache pattern in components/icons.py:_icon_sets_cache, theming/theme_packs.py:_theme_imports_done, observability/log_handler.py:_installed_handler — same pattern as _psycopg dismissed in #2104/#2105) and 1 py/ineffectual-statement false positive (tutorials/mixin.py:371await coro is a real async effect, not an ineffectual expression). No behavior change; full Python suite passes (3428 passed, 15 skipped). (python/djust/_context_provider.py, python/djust/live_view.py, python/djust/components/base.py, python/djust/theming/inspector.py, python/djust/admin_ext/views.py, python/djust/management/commands/djust_audit.py)

  • Cleanup: 36 py/empty-except + 6 misc CodeQL note-severity alerts — Narrowed over-broad except Exception: pass to specific exception types where the call surface was knowable, and added logger.debug(...) (with import logging; logger = logging.getLogger(__name__) where not already present) for optional-feature probes in components/gallery/views.py (optional djust_theming static CSS link), components/icons.py (optional DJUST_COMPONENTS_ICON_SETS setting), auth/admin_views.py (optional django-allauth OAuth stats, 2 sites), auth/djust_admin.py (optional allauth registry), and mixins/context.py (best-effort descriptor resolution). Annotated "skip invalid numeric input" sites with justification comments (+ passcontinue for clarity) across components/templatetags/_charts.py (4), components/rust_handlers.py (8), components/components/{calendar_heatmap,heatmap,line_chart,source_citation}.py, components/descriptors/carousel.py, components/function_component.py (2), components/mixins/data_table.py (3), components/templatetags/djust_components.py (2), and similar narrow/intentional catches in checks.py, components/base.py (optional @event_handler decoration), mixins/waiters.py (idempotent waiter removal), observability/dry_run.py (best-effort bulk-op count), theming/management/commands/djust_theme.py, and theming/templatetags/theme_tags.py. Re-export in components/templatetags/djust_components.py (_get_field_type, _infer_columns, _queryset_to_rows from _forms) made explicit via __all__ (closes py/unused-import #2171). Deleted 3 JS unused-variable declarations: decoder in components/static/djust_components/ttyd/ttyd_terminal.js:35, resolvedMode in theming/static/djust_theming/js/theme.js:416, and getCookie() in theming/static/djust_theming/js/theme.js:449. Dismissed 2 py/unused-global-variable false positives (#2104, #2105 — _psycopg / _psycopg_sql in db/notifications.py are lazy module-level caches assigned via global inside _ensure_psycopg(); CodeQL's scope analyzer doesn't track global-write patterns). 4 note-level py/cyclic-import alerts (#2096, #2112-#2114) left for scanner rescan — expected to auto-close as PR #928's refactor propagates. No behavior change; full Python suite passes (3428 passed, 15 skipped).

  • Code-quality cleanup — ~66 CodeQL note-severity alerts — mechanical fixes: deleted unused imports (treated re-exports with __all__ + # noqa: F401 preservation; replaced side-effect submodule imports with importlib.import_module), removed ~30 unused local variables across rust_handlers.py, templatetags/djust_components.py, components/*.py, and templatetags/_forms.py / _advanced.py, removed ~4 unused module-level names (default_app_config in components/__init__.py, theming/__init__.py, admin_ext/__init__.py — obsolete since Django 3.2 auto-discovery), simplified 3 lambda vals: f(vals) wrappers in AGG_FUNCS (pivot-table aggregations) to bare sum / len, deduped 2 import json / import asyncio occurrences in function_component.py / mixins/data_table.py / db/notifications.py, reconciled import X + from X import Y conflicts in gallery/registry.py and templatetags/djust_components.py, and removed ineffectual single-... statements in Protocol / abstract method bodies in api/auth.py and tenants/audit.py. No behavior change; full suite passes (3428). Plus 3 dismissed with justification: 2 × py/catch-base-exception in async_work.py (existing # noqa: BLE001 comments + documented design intent of surfacing every failure via AsyncResult.errored), and 1 × js/syntax-error on theming/templates/.../theme_head.html (CodeQL's JS analyzer erroneously parsing a Django template as JavaScript).

  • Break themes → _base → presets/theme_packs cyclic import (873 CodeQL alerts) + add explicit event.origin check to service worker message handler — CodeQL's py/unsafe-cyclic-import rule flagged 872 alerts across the theming subsystem: themes/_base.py imported dataclasses + shared style instances from ..presets and ..theme_packs, and those two modules re-imported each theme file under .themes.* at module load — a real cycle that happened to work only because ColorScale / ThemeTokens / etc. were defined earlier in presets.py than the theme imports. Extracted the pure data into two new dependency-free modules: python/djust/theming/_types.py (14 dataclass types: ColorScale, ThemeTokens, SurfaceTreatment, ThemePreset, TypographyStyle, LayoutStyle, SurfaceStyle, IconStyle, AnimationStyle, InteractionStyle, DesignSystem, PatternStyle, IllustrationStyle, ThemePack — stdlib imports only) and python/djust/theming/_constants.py (~60 shared style instances — PATTERN_*, ILLUST_*, ICON_*, ANIM_*, INTERACT_* at both the design-system and pack levels; depends only on _types). themes/_base.py now imports from those two modules, bypassing the cycle; presets.py and theme_packs.py import from the same new modules and re-export every type and instance under __all__ for full backward compat (no theme author touches any import site). Also resolved the pre-existing shadow between two InteractionStyle class definitions (the narrow DS-level InteractionStyle at theme_packs.py:150 was silently shadowed by the wider pack-level one at :1374 — all INTERACT_* module-level instances relied on fields only the wider class had; unified on the superset definition in _types.py) and the INTERACT_MINIMAL / INTERACT_PLAYFUL name collision between the DS-level and pack-level bindings (kept the distinct runtime bindings via _INTERACT_MINIMAL_DS / _INTERACT_PLAYFUL_DS). Also tightened the service-worker message handler in python/djust/static/djust/service-worker.js with an explicit event.origin !== self.location.origin early return at the top of the listener, satisfying CodeQL's js/missing-origin-check rule (alert #2170 — follow-up to the source+scope check shipped in #925). 7 regression cases in python/djust/tests/test_theming_imports_backcompat.py cover: presets/theme_packs type exports still importable, shared instance exports still importable, _base re-exports identical object identity to presets / theme_packs, per-theme files (vercel used as smoke) still construct a full triple, lazy theme-pack registry still populates 71 packs + 73 design systems, and the DS-vs-pack InteractionStyle distinction for minimal / playful is preserved (DS link_hover="underline", pack button_click="ripple" — both bindings round-trip). Expected alert closure: 872 × py/unsafe-cyclic-import + 1 × js/missing-origin-check = 873. (python/djust/theming/_types.py, python/djust/theming/_constants.py, python/djust/theming/presets.py, python/djust/theming/theme_packs.py, python/djust/theming/themes/_base.py, python/djust/static/djust/service-worker.js)

  • Dead conditional in djust/theming/templatetags/theme_form_tags.py — the label-visibility check at line 88 had isinstance(field.widget, template.library.InvalidTemplateLibrary if False else type(None)). The if False else type(None) ternary always evaluated to type(None), making the first operand unreachable dead code (CodeQL py/constant-conditional-expression). Dropped the dead branch; the isinstance check is now isinstance(field.widget, type(None)) with a comment explaining the intent.

  • Close 21 py/undefined-export CodeQL alertsdjust/auth/__init__.py and djust/tenants/__init__.py use a __getattr__-based lazy-import dispatcher to defer Django-ORM-dependent imports. CodeQL's static analysis doesn't recognize this pattern; names declared in __all__ but only resolved via __getattr__ were flagged. Added a TYPE_CHECKING block to each __init__.py with eager import statements gated behind if TYPE_CHECKING: — the imports execute only under static analysis (mypy, CodeQL, IDEs), never at runtime. The lazy-import runtime behavior is unchanged. New python/djust/tests/test_lazy_import_resolution.py (47 parameterized cases) regression-tests that every __all__ entry resolves.

  • 3 real bugs caught by CodeQL scanning (6 alerts closed)python/djust/components/gallery/views.py (py/stack-trace-exposure, 2 alerts): the gallery's per-variant render fallback interpolated the raw Exception repr into the HTML returned to the user (f'<div ...>Render error: {exc}</div>'), leaking internal template / class paths and error detail to any gallery viewer. Fixed to log via logger.exception(...) and return a generic Render error — see server logs message at both the type == "tag" template-render path and the type == "class" render-callable path. python/djust/theming/build_themes.py (py/call-to-non-callable, 1 alert): BuildTimeGenerator.__init__ assigned the generate_manifest: bool constructor argument onto self.generate_manifest, which shadowed the method of the same name at def generate_manifest(self, generated_files). Calling self.generate_manifest(generated_files) at line 521 from build_all() would have raised TypeError: 'bool' object is not callable on any invocation of the full build — the method was effectively unreachable. Renamed the attribute to self._generate_manifest (underscore = internal flag), updated the single consumer inside the method to match; the callable is now callable again. python/djust/theming/accessibility.py (py/str-format/missing-named-argument, 3 alerts): AccessibilityValidator.generate_accessibility_report_html passed an HTML+CSS string through str.format(**kwargs) where the embedded literal CSS braces (body { font-family: ... }) were being parsed by Python's format machinery as placeholder keys, raising KeyError / ValueError at runtime on the very first { it hit. Refactored to keep the CSS in a separate un-formatted string (_css_styles) and feed it as a single {styles} placeholder into the HTML template (_html_template); no double-brace escaping hazard, template semantics preserved. 4 regression cases in python/djust/tests/test_codeql_bugfixes.py cover: exception-message not reflected in either gallery render fallback; generate_manifest(True) calls the method (no TypeError); generate_manifest(False) short-circuits to ""; HTML report renders end-to-end with both <!DOCTYPE html> and surviving CSS font-family tokens. (python/djust/components/gallery/views.py, python/djust/theming/build_themes.py, python/djust/theming/accessibility.py)

Security

  • Client-side markdown preview: escape user input before markdown transforms — closes 1 CodeQL js/xss-through-dom alert (#1978, warning)inlineFormat in python/djust/components/static/djust_components/markdown-textarea.js applied regex-based markdown substitutions on raw user input and wrote the result into the preview pane via innerHTML, so a user typing # <script>alert(1)</script> into their textarea saw the raw <script> tag rendered in their own preview. Self-XSS in most deployments, but propagates to other users wherever a textarea's data-raw payload later lands in another user's view (shared drafts, admin review screens, collaborative editors). Fix: call escapeHtml() at the top of inlineFormat (before any regex transform — the markdown syntax chars *, _, `, [, ], (, ) are not in the escape set so the substitutions still match). Added _sanitizeUrl() that rewrites javascript:, data:, and vbscript: URL schemes (case-insensitive, leading-whitespace tolerant) to # in link targets, closing the [click](javascript:alert(1)) attack surface. 11 JSDOM regression cases in tests/js/markdown_textarea_xss.test.js cover <script> / <img onerror> / <b> escaping in headings / paragraphs / lists, preserved **bold** / *italic* / `code` functionality, javascript: / data: / VBScript: URL rewriting, safe https:// and relative URLs preserved, and fenced-code-block escaping still works. (python/djust/components/static/djust_components/markdown-textarea.js)

  • Service worker postMessage same-origin source check — closes 1 CodeQL js/missing-origin-check alert (#2106, warning)python/djust/static/djust/service-worker.js processed any incoming message event without inspecting event.source. Service workers are inherently same-origin (they cannot be loaded cross-origin, so postMessage from a cross-origin page can't reach the SW), but defense-in-depth: a compromised same-origin frame outside the SW scope could still reach the handler. Fix: two-layer gate before touching event.data — (1) reject messages whose event.source is missing or whose event.source.type is not 'window' (rejects worker / sharedworker clients we don't expect), (2) reject WindowClient sources whose url doesn't start with self.registration.scope. 4 new JSDOM regression cases in tests/js/service_worker.test.js (new describe block "message origin check") cover no-source rejection, non-WindowClient rejection, out-of-scope URL rejection, and valid-WindowClient acceptance. Existing 12 SW tests unchanged — the pre-existing harness was updated to back-fill type: 'window' + a scope-valid url on caller-supplied source objects, preserving the exact inputs each test verifies. (python/djust/static/djust/service-worker.js)

  • Open-redirect + path-traversal hardening + dismiss py/clear-text-* CodeQL false-positives (7 alerts closed/dismissed)Real (3 code fixes, closing 4 alerts): python/djust/auth/views.py SignupView.get_success_url accepted any next POST param and passed it straight to redirect(), so a crafted form post could bounce newly-authenticated users to an attacker-controlled host — fixed by validating with Django's url_has_allowed_host_and_scheme() against the current request host (with require_https=self.request.is_secure()); off-site, protocol-relative (//evil.com), and scheme-different values all fall back to settings.LOGIN_REDIRECT_URL. python/djust/admin_ext/views.py:admin_login_required interpolated request.path directly into the login-redirect query string (?next=<path>), letting a path containing & / # / encoded control chars smuggle extra query params into the redirect — fixed with urllib.parse.urlencode({"next": request.path}). python/djust/theming/gallery/storybook.py:get_component_template_source joined an HTTP-accessible component_name URL kwarg into _COMPONENTS_DIR / f"{name}.html" with no validation — fixed with an allowlist regex ^[a-z0-9_-]+$ plus a resolved-path-under-base check so traversal payloads (../../../etc/passwd, ../secret, foo/bar) return "" instead of reading outside the components directory. False-positives (4 dismissed): py/clear-text-storage-sensitive-data + py/clear-text-logging alerts trace taint from MEDICAL_THEME / LEGAL_THEME constant imports in theming/presets.py — CodeQL's healthcare-PII heuristic matches the word "medical" / "legal" as identifiers, but the tainted values are CSS theme names (palette tokens, radii, font stacks), not healthcare or legal data. Dismissed on GitHub with "won't fix" and justification. 5 regression cases in python/djust/tests/test_security_redirects_paths.py cover off-site / same-site / protocol-relative redirect outcomes plus path-traversal rejection and known-valid component name round-trip. (python/djust/auth/views.py, python/djust/admin_ext/views.py, python/djust/theming/gallery/storybook.py)

  • Drop exception messages from API error responses — closes 8-10 CodeQL py/stack-trace-exposure alerts — Stack traces and exception messages can reveal internal file paths, local variable names, DB schema details, and dependency versions, giving attackers a head-start on probing. Three call sites were rewritten to return generic messages and log the full traceback server-side via logger.exception() instead of echoing str(e) / type(e).__name__: {e} back in the JSON response body. python/djust/theming/inspector.py (3 sites at theme_inspector_api GET/POST + theme_css_api) — these endpoints are publicly accessible with no access gating, so this is real prod exposure. python/djust/observability/views.py (4 sites at reset_view_state mount failure, eval_handler invalid-JSON body, eval_handler TypeError, eval_handler catch-all) — DEBUG-gated dev tools, but CodeQL still flags the response content; consistent generic-message pattern closes the alerts and the full trace is still captured in the standard log stream. python/djust/api/dispatch.py:384 — the serialize_error path's str(exc) dropped in favor of the same generic message the sibling "handler_error" / catch-all "serialize_error" branches already use. Added logger = logging.getLogger(__name__) to the two files that lacked one. 3 regression cases in python/djust/tests/test_stack_trace_exposure.py verify the sentinel exception message is not reflected in the response body. Two alerts on python/djust/components/gallery/views.py:726,762 share the reflective-XSS cookie-flow surface cleared by PR #918 and may auto-close on rescan; if they don't, dismiss-with-justification is appropriate (allowlist-validated values, escape() already applied). (python/djust/theming/inspector.py, python/djust/observability/views.py, python/djust/api/dispatch.py)

  • Escape user input in gallery 404 responses & theme option fragments — closes 6 CodeQL py/reflective-xss alerts (error severity) — Three real reflective-XSS sites in python/djust/theming/gallery/views.py (lines 276, 281, 306): storybook_detail_view and storybook_category_view echoed the user-controlled URL kwargs component_name / category into HttpResponseNotFound(f"Unknown ...: {value}") with Content-Type: text/html, so a visitor hitting /storybook/<script>alert(1)</script>/ got the raw payload reflected in the 404 body. Fix: wrap the interpolations with django.utils.html.escape(). Three defense-in-depth sites in python/djust/components/gallery/views.py (lines 677, 726, 762 via _resolve_theme): cookie values (gallery_ds, gallery_preset) flow through an allowlist validator before being interpolated into <option> fragments, so the genuine attack surface is zero — but CodeQL's taint analyzer doesn't recognize the allowlist pattern. Added escape() on the cookie-derived values' HTML interpolation sites; on validated input this is a no-op (allowlist values are plain ASCII identifiers), and it clears the taint flag for the static analyzer. 4 regression cases in python/djust/tests/test_gallery_xss.py cover both the real-XSS 404 body escaping and the allowlist + escape behavior for malicious cookie values. (python/djust/theming/gallery/views.py, python/djust/components/gallery/views.py)

  • Sanitize user-controlled values in log calls — closes 9 CodeQL py/log-injection alerts — Added djust._log_utils.sanitize_for_log(): strips CR/LF/TAB/control chars, replaces with ?, truncates to 200 chars, always returns a string (None / non-string inputs become their repr). Applied at 5 call sites in python/djust/api/dispatch.py (wrapping view_slug, handler_name) and python/djust/theming/gallery/component_registry.py (wrapping component_name, str(exc)) — the sites where HTTP request data flows into logger.exception / logger.debug calls. Format strings unchanged; djust already uses %s-style lazy logging per CLAUDE.md. 8 unit tests in python/djust/tests/test_log_sanitization.py. No behavior change for non-malicious input.

  • Refresh uv.lock to pull in CVE-fix versions for 8 packages — Addresses 23 open Dependabot alerts (13 unique CVEs). Bumps: Django 4.2.29 → 5.2.13 (CVE floor 4.2.30; tightened pyproject.toml ceiling to <6 to keep the major-version jump out of a security-only PR), cryptography 46.0.5 → 46.0.7 (buffer overflow + DNS name constraints), orjson 3.11.5 → 3.11.8 (deep-recursion DoS, floor 3.11.6), requests 2.32.5 → 2.33.1 (insecure temp-file reuse, floor 2.33.0), Pygments 2.19.2 → 2.20.0 (GUID-matching ReDoS), pytest 8.4.2 → 9.0.3 (tmpdir vulnerability), black 25.11.0 → 26.3.1 (arbitrary file writes from unsanitized cache input, dev-only), python-dotenv 1.2.1 → 1.2.2 (symlink following in set_key). Full Python test suite passes (3428 cases); full JS suite passes (1264 cases). No app code or test changes; lockfile + pyproject.toml Django ceiling only. Also catches Cargo.lock up to the v0.5.5rc1 crate versions (stale at 0.5.3rc1 on origin/main).

Changed

  • Drop black dev dependency; ruff format is now the canonical formatter — Pre-commit config has used ruff + ruff-format hooks since v0.5.x; no Makefile / CI / import site references black. Removed black>=24.10.0 / black>=26.3.1 from the dev group in pyproject.toml and the [tool.black] config section. Ruff already has matching line-length = 100 and target-version = "py39". Permanently closes the Dependabot black CVE alert on the Python 3.9 resolution train (black 26.x dropped 3.9 so that alert couldn't be patched; dropping black removes the surface entirely).

[0.5.4rc1] - 2026-04-22

Fixed

  • PresenceMixin + NotificationMixin — side-effect replay on WS state restoration (#893, #894) — Sibling bugs to #889, found via audit after the UploadMixin fix shipped in #891. Both issues share the same root cause: a mixin's mount()-called method has a process-wide side effect beyond setting instance attrs, and the WS consumer's state-restoration path (which skips mount()) never re-issues the side effect. #893 (Presence): track_presence() calls PresenceManager.join_presence(...) as a per-process singleton registration; after restore, the restored user's presence is invisible to other users and handle_presence_join doesn't fire for the user's own join. #894 (Notifications): listen(channel) calls PostgresNotifyListener.instance().ensure_listening(channel) which issues the Postgres LISTEN channel SQL statement on the current process; after a cross-process restore (server restart between HTTP and WS, sticky-session LB routing WS to a different worker, worker reshuffle under load) the destination process's listener has no subscriptions, so NOTIFYs never reach the restored view. Fix (mirrors PR #891): PresenceMixin._restore_presence() replays join_presence when _presence_tracked=True; NotificationMixin._restore_listen_channels() replays ensure_listening per channel (both convergent under replay — PresenceManager.join_presence overwrites the existing record with identical data so repeated calls are a no-op in effect; ensure_listening explicitly early-returns on known channels); WS consumer's state-restoration path calls both right after _restore_private_state(), alongside the existing _restore_upload_configs() call. All three methods are defensive: missing attributes / backend errors / per-item failures are logged at WARNING and swallowed — restoration must never kill the WebSocket. 11 regression cases in tests/unit/test_mixin_restoration_893_894.py cover both mixins' happy paths, no-op guards (not-tracked, missing user_id, empty channel set, missing attribute), exception handling (backend exception, per-channel failure, postgres unavailable), and an end-to-end session-round-trip test. (python/djust/presence.py, python/djust/mixins/notifications.py, python/djust/websocket.py)

  • UploadMixin — uploads broken after HTTP→WS state restoration (#889) — Production-critical bug affecting every app using UploadMixin with the default pre-rendered HTTP→WS flow. The WS consumer's state-restoration path (websocket.py:1540-1572) skips mount() when pre-rendered session state exists, and the live UploadManager instance isn't JSON-serializable — so _upload_manager silently dropped by _get_private_state(), never restored, and any upload request hit _handle_upload_register with "No uploads configured for this view". Fix: allow_upload() now also records each call as a JSON-serializable dict in self._upload_configs_saved (list of kwarg dicts with primitive values); the new UploadMixin._restore_upload_configs() method replays the saved calls; the WS consumer calls it at the end of the state-restoration path (right after _restore_private_state). Result: restored views behave identically to fresh-mount views. Caveat: allow_upload(writer=CustomWriterClass) — the writer class itself still can't round-trip through JSON; a warning is logged at replay time and the config falls back to the default buffered writer. Apps that rely on custom writers with session restoration need a follow-up design (out of scope for this fix). 10 regression cases in tests/unit/test_upload_restoration_889.py cover: call-list recording, writer-marker flag, multi-slot tracking, JSON round-trip survival, manager rebuild from the list, no-op on empty / missing list, idempotency across repeated restores, writer-fallback warning, and a full HTTP→session→WS-restore end-to-end scenario. (python/djust/uploads.py, python/djust/websocket.py)

Added

  • dj-transition — declarative CSS enter/leave transitions (v0.6.0) — Phoenix JS.transition parity. Three-phase class orchestration so template authors can drive CSS transitions without writing a dj-hook. Attribute value is three space-separated class tokens — phase 1 (start) applied synchronously, phases 2 (active) + 3 (end) applied on the next animation frame so the browser commits the start layout before the transition begins. transitionend removes the active class (phase 3 stays as the final-state). 600 ms fallback timeout covers the display: none / zero-duration corner cases where transitionend never fires. Any attribute-value change re-runs the sequence so authors can retrigger from JS. New static/djust/src/41-dj-transition.js (~120 LOC); document-level MutationObserver matches the dj-dialog / dj-mutation / dj-sticky-scroll registration pattern. 7 JSDOM cases in tests/js/dj_transition.test.js cover spec parsing, phase-1 synchronous application, next-frame phase-2/3 application, transitionend cleanup, fallback-timeout cleanup, global export, and re-trigger-on-attribute-change. This is phase 1 of the v0.6.0 Animations & transitions work; FLIP, dj-remove, dj-transition-group, and skeleton components will ship as separate follow-ups. (python/djust/static/djust/src/41-dj-transition.js)

    See docs/website/guides/declarative-ux-attrs.md.

[0.5.3rc1] - 2026-04-22

Added

  • Runtime layout switching — self.set_layout(path) (v0.6.0) — Phoenix 1.1 parity. An event handler can swap the surrounding layout template (nav, sidebar, footer, wrapper markup) without a full page reload: inner state — form values, scroll position, focused element, dj-hook bookkeeping, third-party-widget references — is fully preserved because the live [dj-root] element is physically moved from the current body into the new layout rather than re-created. Server side: new LayoutMixin in python/djust/mixins/layout.py composed into the LiveView base, queuing at most one pending path (last-write-wins). WebSocket consumer: new _flush_pending_layout() wired at all nine _flush_page_metadata call sites; renders the layout template with the view's current get_context_data() and emits a {"type": "layout", "path": ..., "html": ...} frame. Graceful degradation: TemplateDoesNotExist or any render exception logs a warning and leaves the WS intact. Client side: new static/djust/src/40-dj-layout.js module registered for the layout WS frame — finds the [dj-root] / [data-djust-root] inside the incoming HTML, splices in the live root node, swaps document.body, and fires a djust:layout-changed CustomEvent on document. Handles missing-root payloads and empty HTML gracefully. Tests: 12 Python cases in tests/unit/test_layout_switching.py (mixin, consumer emit/noop/missing-template/no-mixin/view-none, LiveView composition) + 6 JSDOM cases in tests/js/dj_layout.test.js (root-identity preservation, CustomEvent dispatch, malformed-payload refusal, empty-html noop, [dj-root] fallback, global export). Full user guide at docs/website/guides/layouts.md (linked from _config.yaml and index.md). Known limitation: <head> tags are not merged — if a layout needs new stylesheets, add them to the initial layout's <head>. (python/djust/mixins/layout.py, python/djust/mixins/__init__.py, python/djust/live_view.py, python/djust/websocket.py, python/djust/static/djust/src/03-websocket.js, python/djust/static/djust/src/40-dj-layout.js)

[0.5.2rc1] - 2026-04-22

Added

  • WebSocket per-message compression toggle — DJUST_WS_COMPRESSION (v0.6.0) — VDOM patches compress extremely well (repetitive HTML fragments + JSON structure → 60-80 % wire-size reduction via zlib). Uvicorn and Daphne both negotiate permessage-deflate with browsers out of the box, so the wire-level compression is already free in most deployments — this change adds the declarative config toggle + documentation so operators can verify it's active, reason about the ~64 KB per-connection zlib context cost, and disable it cleanly on extreme-connection-density deployments or when running behind a compressing CDN. New websocket_compression config key (default True) exposed via djust.config.config, bridged from a top-level settings.DJUST_WS_COMPRESSION for discoverability, and surfaced to the injected client bootstrap as window.DJUST_WS_COMPRESSION (application code can branch on it to skip manual JSON.stringify optimizations that only help without wire-level compression). 6 tests in tests/unit/test_ws_compression_config.py cover default, override to True/False, truthy/falsy coercion, and client-script emission. Deployment guide (docs/website/guides/deployment.md) gains a new "WebSocket per-message compression" section covering the memory tradeoff, CDN double-compression footgun, and Uvicorn/Daphne flags. (python/djust/config.py, python/djust/mixins/post_processing.py)

  • Declarative UX attributes — dj-mutation, dj-sticky-scroll, dj-track-static (v0.6.0) — Three small client-side declarative attributes that replace boilerplate dj-hooks every production app tends to write. dj-mutation (new static/djust/src/37-dj-mutation.js, ~100 LOC) fires a dj-mutation-fire CustomEvent when the marked element's attributes or children change via MutationObserver, with dj-mutation-attr="class,style" for targeted attribute filters and dj-mutation-debounce="N" for burst coalescing (default 150 ms). dj-sticky-scroll (new 38-dj-sticky-scroll.js, ~90 LOC) keeps a scrollable container pinned to the bottom when children are appended but backs off when the user scrolls up to read history and resumes when they return to the bottom — the canonical chat / log viewer UX with a 1 px sub-pixel tolerance. dj-track-static (new 39-dj-track-static.js, ~90 LOC; Phoenix phx-track-static parity) snapshots tracked <script src> / <link href> values on page load and, on every subsequent djust:ws-reconnected event, diffs against the snapshot — dispatches dj:stale-assets CustomEvent on changed URLs, or calls window.location.reload() when the changed element carried dj-track-static="reload". Without this last one, clients on long-lived WebSocket connections silently run stale JS after a deploy — zero-downtime on the server but broken behavior on connected clients. Supporting change in 03-websocket.js: onopen now dispatches document.dispatchEvent(new CustomEvent('djust:ws-reconnected')) on every reconnect so application code (not just dj-track-static) can hook reconnects without touching internal WS state. Convenience Django template tag {% djust_track_static %} in live_tags.py emits the bare attribute for discoverability. All three attributes live-register via a document-level MutationObserver root (same pattern as dj-dialog) so VDOM morphs that inject or remove the marker re-wire observers automatically. 15 JSDOM test cases across tests/js/dj_mutation.test.js, tests/js/dj_sticky_scroll.test.js, tests/js/dj_track_static.test.js; 4 Python test cases in tests/unit/test_djust_track_static_tag.py. (python/djust/static/djust/src/37-dj-mutation.js, 38-dj-sticky-scroll.js, 39-dj-track-static.js, 03-websocket.js, python/djust/templatetags/live_tags.py)

    See docs/website/guides/declarative-ux-attrs.md.

  • djust.db.untrack(model) — disconnect signal receivers wired by @notify_on_save (#809) — Previously the only way to detach the post_save / post_delete receivers from a @notify_on_save-decorated model was to clear the entire signals.receivers list, which scorched unrelated test fixtures. untrack() now disconnects exactly the two receivers stashed on model._djust_notify_receivers and wipes the introspection attributes (_djust_notify_channel, _djust_notify_receivers) so a re-decoration goes through cleanly with a fresh channel. Returns True on success, False on a never-decorated model — idempotent, safe to call twice. Primarily for pytest teardowns in projects that decorate models at class-definition time. 5 tests in tests/unit/test_db_notifications.py::TestUntrack. Exported from djust.db and documented in the djust.db module docstring. (python/djust/db/decorators.py, python/djust/db/__init__.py)

    See docs/website/guides/database-notifications.md.

  • Pre-minified client.js distribution (v0.6.0 P1) — Production now serves client.min.js (terser-minified) instead of the 35-module readable concat, with .gz and .br pre-compressed siblings built alongside it for whitenoise / nginx static serving. Measured impact: client.js 410 KB → client.min.js 146 KB raw → 39 KB gzip → 33 KB brotli (~92% reduction wire-size over the raw file). DEBUG=True continues to serve the readable client.js so stack traces point at meaningful line numbers and contributors can poke at source directly. An explicit DJUST_CLIENT_JS_MINIFIED setting (bool) overrides the DEBUG heuristic in either direction so operators can validate the minified file locally or keep the readable build in production if they want to debug in-situ. scripts/build-client.sh gained a minify_and_compress helper that runs terser (from node_modules/.bin/terser or PATH), then gzip -9 and brotli -q 11; the step is skipped gracefully when terser isn't installed so contributors can still iterate on raw sources without npm install. Source-maps (.min.js.map) are emitted for production-side debugging. djust.C012 system check now recognizes both client.js and client.min.js in manual-loading detection. 6 tests in tests/unit/test_client_minified.py cover build-artifact presence + size reduction, DEBUG-vs-production script selection, and the explicit override in both directions. (scripts/build-client.sh, python/djust/mixins/post_processing.py, python/djust/checks.py, package.json)

Changed

  • Documented block-handler nesting + loader-access constraints (#803, #804) — Two low-priority gaps deferred from PR #802 are now surfaced in both the Rust-side register_block_tag_handler docstring (crates/djust_templates/src/registry.rs) and the Python-side .pyi stub (python/djust/_rust.pyi). The "no parent-tag propagation" constraint (#804) means a nested block handler is not informed it sits inside a parent handler — pass a hint through context instead. The "no loader access from handlers" constraint (#803) means block handlers cannot call {% render_template %}-style loads — pre-render child templates in the view. Both constraints were silently-true before this change; surfacing them prevents surprise when handler authors reach for features the current dispatcher doesn't yet support. No runtime behavior change. (crates/djust_templates/src/registry.rs, python/djust/_rust.pyi)

Fixed

  • assign_async concurrent same-name cancellation semantics (#793) — Two rapid assign_async("metrics", loader) calls used to race: the first loader's worker thread could still be in-flight when the second call scheduled a new task, and when the slow loader finally completed, its setattr(self, "metrics", AsyncResult.succeeded(stale)) clobbered the fresh AsyncResult.pending() that the second call had just written. assign_async() now maintains a per-attribute generation counter (self._assign_async_gens[name]) bumped on every call; each loader's runner closure captures the generation at creation time and short-circuits on both the success and error paths when a newer call has superseded it. The in-flight stale runner still completes (no mid-flight cancellation), but its result is discarded via a DEBUG log — the fresh pending state survives. 4 regression cases in tests/unit/test_assign_async.py: sync success-path, sync error-path, async-loader success-path, and a generation-counter sanity check. (python/djust/mixins/async_work.py)

  • Template dep-tracking: filter-arg bare identifiers (#787){{ value|default:fallback }} now tracks fallback as a template dependency alongside value. Previously the dep-extractor walked filter chains but dropped all filter arguments, so a pattern like {% if show %}{{ value|default:dynamic }}{% endif %} would fail to re-render when only dynamic changed — the render cache classified the node as dep-clean and the partial-render pipeline skipped it. Literal filter args (default:"none", default:'none', default:0, default:-1) are correctly excluded from the dep set; only bare identifiers and dotted paths are tracked. Landed via a two-step: parse_filter_specs now preserves surrounding quotes on literal args so the extractor can distinguish literals from identifiers, and render-time filter application strips quotes via the new strip_filter_arg_quotes helper. No change to filter runtime semantics. 15 regression cases in tests/unit/test_template_dep_tracking_787_806.py. (crates/djust_templates/src/parser.rs, crates/djust_templates/src/renderer.rs)

  • Template for-iterables resolve through getattr walk (#806){% for x in foo.bar %} now uses Context::resolve (which walks getattr through the raw-PyObject sidecar) with a fallback to Context::get, instead of only consulting the value-stack. Previously dotted iterables silently rendered as empty when the attribute was not a top-level dict key — affecting Django QuerySet relations (user.orders), dataclass attributes, and nested Python objects. Covered by two direct-access tests (nested attributes + relation stub) + existing top-level + empty-block + missing-attr regression tests. (crates/djust_templates/src/renderer.rs)

  • send_pg_notify payload size guard (#810) — PostgreSQL caps NOTIFY payloads at 8000 bytes. send_pg_notify() now warns at 4KB (soft limit) and drops + error-logs at 7500 bytes (hard limit). (python/djust/db/decorators.py)

  • PostgresNotifyListener.areset_for_tests() awaits task cancellation (#811) — The existing reset_for_tests() fire-and-forget cancel is now documented as such; new async variant awaits the cancelled task so async test teardowns don't race. (python/djust/db/notifications.py)

  • db_notify render-lock timeout documented (#813) — 100ms timeout is best-effort under contention; dropped notifications do not queue. (python/djust/websocket.py)

  • Regression test: consumer handles views without NotificationMixin (#812) — Locks in that getattr(view, '_listen_channels', None) + truthy gate handles both absent-attr and empty-set paths. (tests/unit/test_db_notifications.py)

  • stream() with limit=N pre-trims emitted inserts (#799) — Server trims items_list to at-most limit before emitting inserts. (python/djust/mixins/streams.py)

  • teardownVirtualList restores original children (#798) — Teardown now restores pre-virtualization children and removes the shell/spacer. (python/djust/static/djust/src/29-virtual-list.js)

  • stream_prune .children filter redundancy removed (#801) — Cosmetic cleanup. (python/djust/static/djust/src/17-streaming.js)

  • LiveViewTestClient.render_async() invokes handle_async_result (#843) — Test-client drain now mirrors the production WS consumer. (python/djust/testing.py)

  • LiveViewTestClient.follow_redirect() refuses to pick silently when multiple redirects queued (#844) — Raises AssertionError with all queued paths. (python/djust/testing.py)

  • UploadWriter close() return validated as JSON-serializable (#825) — Non-JSON returns caught at finalize time and abort the upload cleanly. (python/djust/uploads.py)

  • BufferedUploadWriter write_chunk() after close() raises (#823)_finalized flag now actively enforced; repeated close() is idempotent. (python/djust/uploads.py)

  • Upload-manager drops trailing chunks silently after abort (#824, partial) — Fast-path at DEBUG log; writer.abort() called once. (python/djust/uploads.py)

  • Morph-path honors dj-ignore-attrs (#815) — The VDOM morph loop at python/djust/static/djust/src/12-vdom-patch.js:746-758 previously stripped and overwrote attributes without consulting djust.isIgnoredAttr. Attributes listed in dj-ignore-attrs would survive individual SetAttr patches (the guard added in PR #814) but could still get wiped during a full-element morph. The morph-path remove-loop and set-loop both now skip ignored attribute names. Two regression tests in tests/js/ignore_attrs.test.js cover remove-loop and set-loop preservation. (python/djust/static/djust/src/12-vdom-patch.js)

Changed

  • dj-ignore-attrs CSV empty-token hardening (#816)isIgnoredAttr now skips empty tokens produced by double-comma ("open,,close") or trailing-comma ("open,") CSV values, and rejects empty attribute-name queries. Previously those edge cases could accidentally match an empty attribute name. Four regression tests in tests/js/ignore_attrs.test.js cover empty string, whitespace-only, double comma, and trailing comma. (python/djust/static/djust/src/31-ignore-attrs.js)

Added

  • djust_typecheck{% firstof %} / {% cycle %} / {% blocktrans with %} tag support (#850) — The extractor now captures positional context-variable references in {% firstof a b c %} and {% cycle a b c %} (string literals and as <name> suffixes are correctly ignored), and the with x=expr (and count x=expr) clauses of {% blocktrans %} / {% blocktranslate %} produce both the template-local binding (x) and the reference (expr). Eliminates a class of false positives (blocktrans locals) and false negatives (firstof/cycle args). (python/djust/management/commands/djust_typecheck.py)

    See docs/website/guides/typecheck.md.

Changed

  • djust_typecheck — walk MRO for parent-class self.foo = ... assigns (#851)_extract_context_keys_from_ast now iterates cls.__mro__ (skipping djust.*, djust_*, django.*, rest_framework.*, and builtins), so a child view that relies on attributes set in a parent mount() no longer produces spurious "unresolved" reports. The filter drops Django's View / namespace-framework attrs (request, head, kwargs, args) that would otherwise surface from the base class. (python/djust/management/commands/djust_typecheck.py)

  • Shared class-introspection helpers (#852)_walk_subclasses, _is_user_class, and _app_label_for_class are now a single source of truth in the new djust.management._introspect module; djust_audit and djust_typecheck both import from it. No behavior change; purely a refactor to prevent drift as the set of management commands grows. _introspect.walk_subclasses also gained cycle-safety (diamond-inheritance deduplication) which the old recursive implementation lacked. (python/djust/management/_introspect.py, python/djust/management/commands/djust_audit.py, python/djust/management/commands/djust_typecheck.py)

  • Service worker + main-only middleware follow-ups to PR #826 (closes #827/#828/#829/#830)

    • #828DjustMainOnlyMiddleware now early-returns on responses with status_code >= 400. Error pages render full-page layouts (status message, "go back" link, etc.); trimming them to <main> would strip that context from shell-navigation clients. Regression tests cover 4xx and 5xx.
    • #830 — HTML response detection widened to include application/xhtml+xml in addition to text/html. Charset and boundary suffixes (text/html; charset=utf-8; boundary=xyz) are stripped before matching. Defensive test confirms application/rss+xml is still treated as non-HTML.
    • #829djust.registerServiceWorker() is now idempotent. A second call returns the cached registration promise without re-running initInstantShell / initReconnectionBridge, so drain listeners and the WS sendMessage patch are applied at most once. Previous behavior caused buffered replays to double on repeat init.
    • #827 — Documented the <script>-inside-<main> limitation of the instant-shell innerHTML swap at the top of 33-sw-registration.js. The doc block was also corrected: dj-click/dj-submit/etc. work through document-level event delegation (not MutationObserver), and dj-hook now explicitly re-runs via a djust.reinitAfterDOMUpdate(placeholder) call after the swap — dj-hook content inside <main> actually works post-swap as a result (previous implementation silently skipped hook re-binding).

    Tests: 9 → 13 Python cases in tests/unit/test_main_only_middleware.py, +2 JS cases in tests/js/service_worker.test.js (12 total). (python/djust/middleware.py, python/djust/static/djust/src/33-sw-registration.js)

[0.5.1rc4] - 2026-04-22

Added

  • Transport-conditional API returns — api_response() convention + @event_handler(expose_api=True, serialize=...) override (v0.5.1 P2 follow-up to ADR-008) — Handlers serving both WebSocket and HTTP API callers often have split needs: WS only wants state mutation (VDOM renders the UI), HTTP wants actual data in the response. Serializing query results on every WS keystroke is wasteful. Resolved with three-tier resolution on the HTTP path (zero overhead on WS): (1) per-handler @event_handler(expose_api=True, serialize=<callable-or-str>) wins when set; (2) otherwise the view's api_response(self) method is called (the DRY convention — one method, many handlers); (3) otherwise the handler's return value passes through unchanged. serialize= accepts a callable (arity-detected: fn() / fn(view) / fn(view, result)) or a method-name string resolved against the view at dispatch time. Async serializers and async api_response() are both awaited. serialize= without expose_api=True raises TypeError at decoration. Missing method or serializer exception → 500 serialize_error (details logged server-side only); PermissionDenied raised from either path surfaces as 403 (not 500). The self._api_request = True flag is set by dispatch before mount() runs so mount can branch on transport; it is retained as an escape hatch for code that needs transport awareness without the decorator plumbing. 28 tests in python/djust/tests/test_api_response.py cover unit-level resolution (passthrough, convention, per-handler override, arity detection, async paths, MRO-provided api_response, shadowed non-callable api_response, invalid spec types, staticmethod-via-string, callable class instances) and end-to-end dispatch integration (including PermissionDenied surfacing as 403 and the mount-time flag availability). Full guide in docs/website/guides/http-api.md under "Transport-conditional returns". (python/djust/decorators.py, python/djust/api/dispatch.py)

[0.5.1rc3] - 2026-04-21

Added

  • LiveView testing utilities (v0.5.1 P2) — Seven new methods on LiveViewTestClient for Phoenix LiveViewTest parity: assert_push_event(event_name, params=None) verifies a handler queued a client-bound push event (payload match is subset-based so tests stay resilient to later payload additions); assert_patch(path=None, params=None) / assert_redirect(path=None, params=None) assert live_patch / live_redirect calls; render_async() drains pending start_async / assign_async tasks synchronously so subsequent assertions see their results; follow_redirect() resolves the queued redirect via Django's URL router and returns a new test client mounted on the destination view; assert_stream_insert(stream_name, item=None) verifies stream operations (item subset-match for dicts); trigger_info(message) synthetically delivers a handle_info message so pubsub / pg_notify handlers can be tested without real backend wiring. Full user-facing guide at docs/website/guides/testing.md. 21 new test cases. (python/djust/testing.py)
  • dj-dialog — native <dialog> modal integration (v0.5.1 P2) — Declarative opt-in for the HTML <dialog> element's built-in modal behavior. Mark a <dialog> with dj-dialog="open" to call showModal() (backdrop, focus-trap, and Escape handling all browser-native); set dj-dialog="close" to call close(). A document-level MutationObserver watches for attribute changes and DOM insertions so VDOM morphs that swap dj-dialog work automatically without per-element re-registration. Idempotent — re-asserting "open" on an already-open dialog is a no-op; gracefully ignores non-<dialog> elements carrying the attribute. ~80 LOC JS in python/djust/static/djust/src/35-dj-dialog.js. 8 JSDOM tests in tests/js/dj_dialog.test.js.
  • Type-safe template validation — manage.py djust_typecheck (v0.5.1 P2, differentiator) — Static analysis that reads every LiveView template, extracts every variable and tag reference, and reports names not covered by the view's declared context. "Declared context" is the union of public class attributes, self.foo = ... assignments anywhere in the class (AST-extracted — not run), @property methods, literal-dict keys returned from get_context_data, template-local bindings ({% for %} / {% with %} / {% inputs_for as %}), framework built-ins (user, request, csrf_token, forloop, djust, etc.), and anything listed in settings.DJUST_TEMPLATE_GLOBALS. Silencing: per-template pragma ({# djust_typecheck: noqa name1, name2 #}), per-view strict_context = True opt-in, or the project-wide globals setting. Flags: --json, --strict, --app, --view. Neither Phoenix nor React catches template-variable typos statically without an external type system — this is a genuine djust differentiator. 14 tests in python/djust/tests/test_djust_typecheck.py. Full guide at docs/website/guides/typecheck.md. (python/djust/management/commands/djust_typecheck.py)
  • Dev-mode error overlay (v0.5.1 P2) — Next.js/Vite-style full-screen error panel that renders in the browser whenever a LiveView handler raises an exception and Django DEBUG=True. Displays the error message, the event that triggered the handler, the server-sent Python traceback, an optional hint, and validation details when present. Dismissal: Escape key, close button, or backdrop click. A second error replaces the current panel rather than stacking. All field values HTML-escaped to prevent traceback injection. Gated on window.DEBUG_MODE — production builds render nothing (Django also strips traceback / debug_detail / hint from the error frame in non-DEBUG mode, so there's nothing to leak). Exposes window.djustErrorOverlay.show(detail) / .dismiss() for manual invocation from devtools. 10 JSDOM tests in tests/js/error_overlay.test.js. Full guide at docs/website/guides/error-overlay.md. (python/djust/static/djust/src/36-error-overlay.js)
  • Nested formset helpers — {% inputs_for %} + FormSetHelpersMixin (v0.5.1 P2) — djust-native support for Django formset / inline-formset patterns. Template side: {% inputs_for formset as form %}...{% endinputs_for %} iterates any BaseFormSet and exposes each bound child form with its per-row prefix intact so rendered inputs submit under the correct Django-expected names; loop metadata (inputs_for_loop.counter, .counter0, .first, .last) mirrors the {% for %} conventions. Server side: djust.formsets.add_row(cls, data=..., prefix=...) and remove_row(cls, row_prefix, data=..., prefix=...) handle management-form bookkeeping — add_row bumps TOTAL_FORMS (capped at max_num when set, absolute_max otherwise) and preserves existing row data; remove_row writes the standard DELETE=on flag so formset.deleted_forms picks it up on save(). FormSetHelpersMixin wires pre-baked add_row / remove_row event handlers to a formset_classes = {"addresses": AddressFormSet} declaration, with the formset name doubling as the prefix so multiple formsets on one view don't collide on management-form keys. Fails loud if mount() forgets to initialize self._formset_data. 16 tests in python/djust/tests/test_formsets.py. (python/djust/formsets.py, python/djust/templatetags/djust_formsets.py)

[0.5.1rc2] - 2026-04-21

Added

  • Scaffold CSS — reusable layout/utility pack in djust.themingdjust_theming/static/djust_theming/css/scaffold.css gains ~729 lines of framework-generic scaffold covering typography, responsive grid utilities (.grid-2/3/4), hero section, flash messages (Django + LiveView), accessibility utilities (.sr-only), extended layout helpers (.flex-center, .content-narrow/-wide), stat-display variants, auth layout, live indicator dot, card-accent variants, code blocks, noise texture overlay, shared nav links, dashboard/centered grids, and the full data-layout switching system (sidebar, topbar, dashboard, centered, sidebar-topbar). All new rules use CSS-variable fallbacks so the scaffold works without a loaded theme; no hardcoded hex colors; .container max-width now reads var(--container-width, 1200px). Pure-CSS addition — no Python/JS/test behavior changes. (PR #836)

    See docs/website/guides/migration-from-standalone-packages.md.

Fixed

  • All 82 pre-existing test failures resolved (PR #841) — The make test baseline went from 2135 passed, 61 failed, 21 errors (which had blocked normal merges for the entire v0.5.1 milestone and forced --admin on every PR) to 2219 passed, 0 failed, 0 errors. Four fix clusters:
    • Test-infrastructure shims (64 fixes) — added tests/gallery_test_urls.py and tests/test_critical_css.py URL-conf shims that theming tests reference via @override_settings(ROOT_URLCONF=...) but were never created; added mcp[cli]>=1.2.0; python_version >= '3.10' to dev deps so djust.mcp server tests stop throwing ModuleNotFoundError.
    • Stale @layer test expectations (4 fixes) — several theming CSS files (components.css, layouts.css, pages.css, critical-CSS generator) were intentionally unwrapped from @layer blocks for specificity reasons (documented in file headers); updated tests to match the current design using @layer NAME { block-syntax regex rather than substring match.
    • Real code bugs (3 fixes) — ocean_deep preset's internal name was "ocean" while its registry key was "ocean_deep"; one stray text-align: left in components.css .tp-select-option broke RTL support (changed to text-align: start); and the CSS prefix generator's hand-maintained _COMPONENT_CLASSES list had drifted from components.css.btn-edit, .btn-remove, .avatar, .breadcrumb, .dropdown and many more weren't being prefixed when a custom css_prefix was set. Replaced with auto-extraction via regex over the static file; stays in sync automatically.
    • Stale test assumption (1 fix) — test_list_same_content_no_render encoded a wrong assumption about _snapshot_assigns (identity-based by design); rewrote to match the documented contract.
  • CSS prefix generator hardening — Auto-extraction regex gained a negative lookbehind (?<![\w]) to prevent capturing domain fragments inside data-URIs (previously .org and .w3 in http://www.w3.org/2000/svg were mis-captured as class selectors, producing http://www.dj-w3.dj-org/2000/svg under prefix); compound state-class chains like .wizard-step.completed now correctly leave the trailing state class unprefixed (JS toggles state classes by bare name, so they must NOT get the prefix). Two new regression tests (test_data_uri_domains_are_not_mis_prefixed, test_compound_state_classes_stay_unprefixed) lock both in.

Changed

  • ROADMAP.md audit correction — Five entries marked as "v0.5.1 Not started" were actually shipped earlier: djust-theming fold (v0.5.0 PR #772), WizardMixin (PR #632), Error boundaries (v0.5.0 PR #773), and dj-lazy lazy LiveView hydration (PR #54). All marked with strikethrough + ✅ and a shipped-in PR pointer. Real v0.5.1 remainder after audit: LiveView testing utilities, type-safe template validation, error overlay, inputs_for nested formsets, native <dialog> (5 items instead of 8).

[0.5.1rc1] - 2026-04-21

Added

  • Form & submit polish batch (v0.5.1 P2) — Three related form-UX primitives:

    • dj-no-submit="enter" — Prevent Enter-key form submission from text inputs. Fixes the #1 form UX annoyance where pressing Enter to confirm a field accidentally submits the whole form. Textareas (multi-line input), submit-button clicks, and modified keys (Shift+Enter, Ctrl+Enter) are unaffected. Supports comma-separated modes (currently only "enter") for future expansion. Document-level keydown listener — DOM morphs don't need re-registration. (python/djust/static/djust/src/34-form-polish.js)
    • dj-trigger-action + self.trigger_submit(selector) — Bridge successful djust validation to a native HTML form POST. Essential for OAuth redirects, payment gateway handoffs, and anywhere the final step needs a real browser POST. The server calls self.trigger_submit("#form-id") after validation passes; the client receives the push event, verifies the target form carries dj-trigger-action (explicit opt-in — refusal is logged in debug mode), and calls the form's native .submit(). (python/djust/mixins/push_events.py, python/djust/static/djust/src/34-form-polish.js)
    • dj-loading="event_name" shorthand — Declarative scoped loading indicator: <div dj-loading="search">Searching...</div> shows only while the search event is in-flight. Previously required combining dj-loading.show + dj-loading.for="event_name" with an inline style="display:none". The shorthand auto-hides the element on register (no inline style required) and treats the attribute value as both the event-scope and the implicit .show trigger. Coexists with the existing dj-loading.* modifier family. (python/djust/static/djust/src/10-loading-states.js)

    Tests: 11 JS test cases in tests/js/form_polish.test.js covering every happy path and failure mode; 4 Python tests in python/djust/tests/test_trigger_submit.py locking in the push-event shape. Client.js: 35 → 36 source modules (+~120 LOC JS, +~30 LOC Python). Scoped scoped-loading dj-loading="event" implementation reuses existing globalLoadingManager infrastructure — no duplication.

  • State & computation primitives batch (v0.5.1 P2) — Four small related primitives for derived state, dirty tracking, stable IDs, and cross-component context sharing:

    • Memoized @computed("dep1", "dep2")@computed now accepts an optional tuple of dependency attribute names. When given, the value is cached on the instance and only recomputed when any dep's identity or shallow content fingerprint changes (id + length + key subset matching _snapshot_assigns semantics). Plain @computed (no args) retains property semantics — recomputes every access. React useMemo equivalent. (python/djust/decorators.py)
    • Automatic dirty tracking — self.is_dirty / self.changed_fields / self.mark_clean() — Track which public view attributes have changed since a baseline captured after mount(). changed_fields returns a set of attr names that differ from the baseline; is_dirty is bool(changed_fields); mark_clean() resets the baseline (call after a successful save). Use cases: "unsaved changes" warnings (beforeunload), conditional save buttons, optimized handle_event that skips work when nothing changed. Respects static_assigns and ignores private attrs. The WebSocket consumer and the HTTP API dispatch view both capture the baseline after mount. (python/djust/live_view.py, python/djust/websocket.py, python/djust/api/dispatch.py)
    • Stable self.unique_id(suffix="") — React 19 useId equivalent. Returns a deterministic per-view ID stable across renders of the same logical position. Useful for aria-labelledby, form field IDs, and any element that needs a consistent identifier across re-renders. Format: djust-<viewslug>-<n>[-<suffix>]. Counter resets via reset_unique_ids() at render boundaries. (python/djust/live_view.py)
    • Component context sharing — self.provide_context(key, value) / self.consume_context(key, default=None) — React Context API equivalent. A parent view or component exposes a value under key; descendants look it up with consume_context, walking the _djust_context_parent chain. Scoped per render tree; clear_context_providers() resets. (python/djust/live_view.py) See docs/website/guides/state-primitives.md.
  • Auto-generated HTTP API from @event_handler (v0.5.1 P1 HEADLINE, ADR-008) — Opt-in @event_handler(expose_api=True) exposes a handler at POST /djust/api/<view_slug>/<handler_name>/ with an auto-generated OpenAPI 3.1 schema served at /djust/api/openapi.json. Unlocks non-browser callers (mobile, S2S, CLI, AI agents) without duplicating business logic — the HTTP transport is a thin adapter over the existing handler pipeline, reusing validate_handler_params(), check_view_auth(), check_handler_permission(), and the same _snapshot_assigns() / _compute_changed_keys() diff machinery the WebSocket path uses. One stack, one truth (manifesto #4). New package djust.api with DjustAPIDispatchView (dispatch view), api_patterns() (URL factory), OpenAPISchemaView (schema endpoint), SessionAuth + pluggable BaseAuth protocol (auth classes may opt out of CSRF via csrf_exempt = True), and a registry that walks LiveView subclasses with exposed handlers. LiveView gains two read-only contract attributes: api_name (stable URL slug) and api_auth_classes (auth class list). Response shape mirrors the WS assigns-diff: {"result": <return>, "assigns": {<changed public attrs>}}. Error shapes are structured with error / message / details — 400 validation, 401 unauth, 403 denied or CSRF fail, 404 unknown view/handler or handler not expose_api=True, 429 rate limit, 500 handler exception (exception messages logged server-side only, never leaked to the client). Rate limiting: HTTP uses a process-level LRU-capped token bucket keyed on (caller, handler_name) honoring the handler's @rate_limit settings; WebSocket continues to use its per-connection ConnectionRateLimiter. The two transports share rate/burst values but separate bucket storage — a caller using both draws from both independently (a shared-bucket refactor is tracked as a follow-up). manage.py djust_audit now lists every expose_api=True handler and flags any missing @permission_required — treat an exposed handler like @csrf_exempt. Out of scope per ADR-008: streaming responses, GraphQL batching, first-party token auth, Swagger UI hosting, per-handler URL customization. Full guide at docs/website/guides/http-api.md. (python/djust/api/, python/djust/decorators.py, python/djust/live_view.py, python/djust/management/commands/djust_audit.py)

  • Service worker core improvements — instant page shell + WebSocket reconnection bridge (v0.5.0 P3, opt-in) — Two independent SW features that close the v0.5.0 milestone. Both are OFF by default; users opt in explicitly via djust.registerServiceWorker({ instantShell: true, reconnectionBridge: true }) from their own init code. No auto-registration.

    • Instant page shell. The SW caches the first navigation's response split into a "shell" (everything outside <main>) and "main" (inside). Subsequent navigations serve the cached shell immediately with a <main data-djust-shell-placeholder="1"> placeholder; the client then fetches the current URL with X-Djust-Main-Only: 1 and swaps in the fresh <main> contents. Shell/main split uses a single non-greedy regex — nested <main> inside HTML comments or </main> inside CDATA are documented limitations (full HTML parser deferred). Server side honors the header via the new djust.middleware.DjustMainOnlyMiddleware, which extracts the first <main>…</main> inner HTML, updates Content-Length, and stamps X-Djust-Main-Only-Response: 1. The middleware only touches HTML responses; JSON / binary / streaming responses pass through unchanged. Ordering-safe — it can sit anywhere in MIDDLEWARE that sees the rendered response.
    • WebSocket reconnection bridge. Client-side wraps LiveViewWebSocket.sendMessage so that when ws.readyState !== OPEN the serialized payload is posted to the SW via postMessage({type: 'DJUST_BUFFER', connectionId, payload}) instead of being dropped. The SW stores messages in an in-memory Map keyed by connection id, capped at 50 per connection (oldest dropped). On reconnect the client fires DJUST_DRAIN; the SW returns the buffered payloads and the client replays each via ws.ws.send(). Per-page-load connection ids isolate buffers across tabs. IndexedDB persistence and server-side sequence-dedup replay are deferred to v0.6 (best-effort replay today).
    • Files: python/djust/static/djust/service-worker.js (new, standalone — NOT bundled into client.js), python/djust/static/djust/src/33-sw-registration.js (new, concatenated into client.js), python/djust/middleware.py (new), python/djust/config.py (new service_worker defaults sub-dict), tests in tests/js/service_worker.test.js (10 cases) and tests/unit/test_main_only_middleware.py (7 cases), full guide at docs/website/guides/service-worker.md.
  • UploadWriter — raw upload byte-stream access for direct-to-S3 / GCS streaming (Phoenix 1.0 parity, v0.5.0 P2) — New UploadWriter base class in djust.uploads with an open()write_chunk(bytes)close() -> Any / abort(error) lifecycle, wired into allow_upload(name, writer=MyWriter). When a writer is configured, binary WebSocket chunks are piped straight to the writer without buffering to disk or RAM — zero temp file, zero entry._chunks. Writers are instantiated lazily per upload on the first chunk (so abandoned uploads never open an S3 multipart upload), opened exactly once, fed write_chunk() per client frame, and finalized via close() whose return value is stored on UploadEntry.writer_result and rendered in the upload-state context as {{ entry.writer_result }}. Any failure (open or write_chunk raised, close() raised, size-limit exceeded, client cancelled, WebSocket disconnected via UploadManager.cleanup()) routes through abort(BaseException) with the raw exception so writers can release server-side resources (e.g. AbortMultipartUpload); abort() is wrapped to swallow its own exceptions so a failing S3 cleanup never propagates into the request path. Includes BufferedUploadWriter helper that accumulates client-sent 64 KB chunks until a configurable buffer_threshold (default 5 MB — S3 MPU minimum part size except for the last) and calls on_part(bytes, part_num) so subclasses work with S3-aligned parts without worrying about raw client chunk size. Legacy (no-writer=) disk-buffered path is untouched byte-for-byte — backward compatible. Documented in docs/website/guides/uploads.md with a full S3 multipart example. (python/djust/uploads.py, python/djust/websocket.py)

  • dj-ignore-attrs — per-element client-owned attribute opt-out (Phoenix 1.1 JS.ignore_attributes/1 parity, v0.5.0 P2) — Mark specific HTML attributes as client-owned so VDOM SetAttr patches skip them. <dialog dj-ignore-attrs="open"> prevents the server from resetting the open attribute that the browser manages; <div dj-ignore-attrs="data-lib-state, aria-expanded"> protects third-party JS state. Comma-separated list with whitespace tolerance. The guard sits inside applySinglePatch's case 'SetAttr' after the UNSAFE_KEYS check; the attribute write is skipped entirely (and breaks out of the case) when the element opts out. RemoveAttr is intentionally unaffected. Implementation: globalThis.djust.isIgnoredAttr(el, key) helper (~20 lines JS) plus a three-line check in the patch site. (python/djust/static/djust/src/31-ignore-attrs.js, python/djust/static/djust/src/12-vdom-patch.js)

  • {% colocated_hook %} template tag + runtime extraction (Phoenix 1.1 ColocatedHook parity, v0.5.0 P2) — Write hook JavaScript inline alongside the template that uses it, instead of in a separate file. {% colocated_hook "Chart" %}hook.mounted = function() { renderChart(this.el); };{% endcolocated_hook %} emits a <script type="djust/hook" data-hook="Chart"> tag with a /* COLOCATED HOOK: Chart */ auditor banner. The client runtime walks script[type="djust/hook"] elements on init and after each VDOM morph (reinitAfterDOMUpdate), registers each body as window.djust.hooks[name] via new Function, and marks the script with data-djust-hook-registered="1" so re-scans are idempotent. Optional namespacing via DJUST_CONFIG = {"hook_namespacing": "strict"} prefixes data-hook with <view_module>.<view_qualname> so two views can each define Chart without colliding; per-tag opt-out with {% colocated_hook "X" global %}. Namespacing is OFF by default for compat. Security: the body is template-author JS (same trust level as any other template JS); </script> / </SCRIPT> are escaped in the tag's render() to prevent premature tag close. Apps on strict CSP without 'unsafe-eval' should continue using the traditional registration pattern. (python/djust/static/djust/src/32-colocated-hooks.js, python/djust/templatetags/live_tags.py, python/djust/config.py, docs/website/guides/hooks.md)

  • Database change notifications — PostgreSQL LISTEN/NOTIFY → LiveView push (v0.5.0 P1) — Subscribe LiveViews to Postgres pg_notify channels so database changes push real-time updates to every connected user with zero explicit pub/sub wiring. Three APIs: @notify_on_save(channel="orders") model decorator hooks Django post_save / post_delete and emits NOTIFY <channel>, <json>; self.listen("orders") in mount() subscribes the view (joins a Channels group named djust_db_notify_<channel>); def handle_info(self, message) receives {"type": "db_notify", "channel": ..., "payload": {"pk": ..., "event": "save"|"delete", "model": "app.Model"}} and re-renders via the standard VDOM diff path. A process-wide PostgresNotifyListener owns one dedicated psycopg.AsyncConnection (outside Django's pool — long-lived LISTEN connections don't play nice with pgbouncer transaction pooling) and runs async for notify in conn.notifies():, bridging every NOTIFY into channel_layer.group_send(...). Channel names are strictly validated (^[a-z_][a-z0-9_]{0,62}$) at registration and listen time — load-bearing because Postgres NOTIFY doesn't accept bind parameters for the channel identifier. send_pg_notify(channel, payload) is a public helper for Celery tasks / management commands. Non-postgres backends no-op gracefully (debug-logged); self.listen() raises DatabaseNotificationNotSupported when psycopg or a postgres backend isn't available. Known limitation: notifications emitted while the listener's TCP connection is dropped are lost — listener auto-reconnects with 1s backoff and re-issues LISTEN for all subscribed channels, and WS mount() re-fetch handles the client-side recovery case. Documented in docs/website/guides/database-notifications.md. (python/djust/db/decorators.py, python/djust/db/notifications.py, python/djust/mixins/notifications.py, python/djust/websocket.py)

  • PyO3 getattr fallback for model attribute access (v0.5.0 P1 — Rust template engine parity) — Templates can now reference Django model instances passed through context without manual dict conversion. {{ user.username }} resolves via Python getattr when user is a raw Python object rather than a JSON-serialized dict. Implementation: Python's _sync_state_to_rust() builds a sidecar of non-JSON-friendly context values and forwards them via the new RustLiveView.set_raw_py_values() method; Rust's Context::resolve() tries the normal value-stack path first, then walks getattr on attached PyObjects one segment at a time. PyAttributeError (and any property-descriptor exceptions) are caught — missing attrs render as empty, matching Django's TEMPLATE_STRING_IF_INVALID default. Value stays Serialize-friendly (no Value::PyObject variant); sidecar lives outside the Value enum via Arc<HashMap<String, PyObject>> on Context. (crates/djust_core/src/context.rs, crates/djust_live/src/lib.rs, python/djust/mixins/rust_bridge.py)

  • register_assign_tag_handler() for context-mutating template tags (v0.5.0 P1 — Rust template engine parity) — New tag-handler variety complementing register_tag_handler (emits HTML) and register_block_tag_handler (wraps content). An assign tag's render(args, context) method returns a dict[str, Any] that's merged into the template context for subsequent sibling nodes — no HTML output. Enables {% assign slot var_name %}-style patterns. Supported inside {% for %} loops (per-iteration mutation). Registered via djust._rust.register_assign_tag_handler(name, handler). New Node::AssignTag variant; partial-renderer emits "*" wildcard dep so downstream nodes always re-render on context changes. (crates/djust_templates/src/registry.rs, crates/djust_templates/src/parser.rs, crates/djust_templates/src/renderer.rs) See docs/website/guides/template-cheatsheet.md.

  • dj-virtual — Virtual / windowed lists with DOM recycling (v0.5.0 P1) — Render only the visible slice of a large list, recycling DOM nodes as the user scrolls. <div dj-virtual="items" dj-virtual-item-height="48" dj-virtual-overscan="5" style="height: 600px; overflow: auto;"> keeps ~visible-plus-overscan children in the DOM even if the pool has 100K entries. Implementation: fixed-height windowing via transform: translateY(...) on an inner shell plus a hidden spacer for scrollbar length, scroll handler batched through requestAnimationFrame, real element identity preserved across scrolls for hook/framework compatibility. Integrates with the VDOM morph pipeline: new containers are picked up by reinitAfterDOMUpdate, and djust.refreshVirtualList(el) is available for explicit repaints. djust.teardownVirtualList(el) disconnects observers for unmounted containers. (python/djust/static/djust/src/29-virtual-list.js) See docs/website/guides/large-lists.md.

  • dj-viewport-top / dj-viewport-bottom — Bidirectional infinite scroll (Phoenix 1.0 parity, v0.5.0 P1) — Fire server events when the first or last child of a stream container enters the viewport via IntersectionObserver. <div dj-stream="messages" dj-viewport-top="load_older" dj-viewport-bottom="load_newer" dj-viewport-threshold="0.1">. Once-per-entry firing (matches Phoenix) via a data-dj-viewport-fired sentinel; call djust.resetViewport(container) or replace the sentinel child to re-arm. New server-side stream() limit=N kwarg and stream_prune(name, limit, edge) method emit a stream_prune op that trims children from the opposite edge so chat apps, activity feeds and log viewers can stream bidirectionally without unbounded DOM growth. (python/djust/static/djust/src/30-infinite-scroll.js, python/djust/static/djust/src/17-streaming.js, python/djust/mixins/streams.py)

  • assign_async / AsyncResult (v0.5.0 P1) — High-level async data loading inspired by Phoenix LiveView's assign_async. Call self.assign_async("metrics", self._load_metrics) in mount() (or any event handler); the attribute is set to AsyncResult.pending() immediately, the loader runs via the existing start_async infrastructure, and on completion the attribute becomes AsyncResult.succeeded(result) or AsyncResult.errored(exc). Templates read the three mutually-exclusive states via {% if metrics.loading %}…, {% if metrics.ok %}{{ metrics.result }}…, {% if metrics.failed %}{{ metrics.error }}…. Sync and async def loaders are both supported; multiple calls in the same handler load concurrently. Cancellation piggybacks on cancel_async("assign_async:<name>"). (python/djust/async_result.py, python/djust/mixins/async_work.py)

  • {% dj_suspense %} block tag for template-level loading boundaries (v0.5.0 P1) — Declarative counterpart to assign_async: wrap a section depending on one or more AsyncResult assigns, and the boundary emits a fallback while any are loading, an error div if any failed, or the body once all are ok. Explicit await="metrics,chart" syntax keeps the tag debuggable — no reflection magic. Fallback templates are loaded via Django's template loader; unspecified fallbacks render a minimal spinner. Nested suspense boundaries resolve independently. Registered alongside {% call %} in the Rust template engine — no parser/renderer changes. (python/djust/components/suspense.py, python/djust/components/rust_handlers.py) See docs/website/guides/loading-states.md.

  • Function components via @component decorator (v0.5.0 P1 batch) — Stateless Python render functions registerable as template-invokable components. @component def button(assigns): ... is callable from templates via {% call "button" variant="primary" %}Go{% endcall %} (with {% component %} as a synonymous alias). Closes the middle ground between raw HTML and full LiveComponent classes for the ~80% of UI pieces (buttons, cards, badges, icons) that are stateless. clear_components() helper exposed for tests. (python/djust/components/function_component.py, python/djust/__init__.py)

  • Declarative component assigns and slots (Phoenix.Component parity)Assign("variant", type=str, default="default", values=["primary", "danger"], required=True) and Slot("col", multiple=True) DSL, declared on a LiveComponent class attribute (assigns = [...] / slots = [...]) or on function components via @component(assigns=[...], slots=[...]). Validation runs at mount/invoke: required-missing raises AssignValidationError in DEBUG and warns in production, type coercion (str → int / bool / float) is automatic, enum violations via values= raise. Child-class assigns extend (and override by name) parent declarations via MRO walk. (python/djust/components/assigns.py, python/djust/components/base.py) See docs/website/guides/components.md.

  • Named slots with attributes via {% slot %} / {% render_slot %} tags — Parent templates pass named content blocks with attributes into components: {% call "card" %}{% slot header label="Title" %}Header{% endslot %}Body{% endcall %}. Multiple same-name slots collect into a list (essential for table columns, tab panels). Slots are exposed to the component as assigns["slots"] = {name: [{"attrs": {...}, "content": "..."}, ...]}. Non-slot content in the {% call %} body becomes children / inner_block. Implemented in pure Python via a sentinel-and-extract protocol — zero Rust parser/renderer changes. (python/djust/components/function_component.py)

Fixed

  • Attribute-context HTML escaping parity with Django (v0.5.0 P1 — Rust template engine parity) — Variables inside HTML attribute values now route through a dedicated html_escape_attr() that's guaranteed to escape "&quot; and '&#x27; (in addition to &/</>). Detection reuses the existing is_inside_html_tag_at() parser helper — the per-Node::Variable in_attr flag is computed at parse time, so renderer cost is a bool check. |safe still bypasses escaping in both attribute and text contexts. Today's behaviour is unchanged (the base html_escape already covered quotes) — this refactor makes the parse-time classification visible to the renderer so future changes to the default escape can't accidentally break attribute values like <a href="{{ url }}"> when url contains quotes. (crates/djust_templates/src/parser.rs, crates/djust_templates/src/filters.rs, crates/djust_templates/src/renderer.rs)
  • Inline conditional {{ x if cond else y }} now contributes deps to enclosing wrappers (#783, sibling bug) — Same failure mode as nested {% include %}: extract_from_nodes had no arm for Node::InlineIf, so its true_expr / condition / false_expr variables were silently dropped from the dep set of any surrounding {% if %} / {% for %} / {% with %}. Changing the condition alone (e.g. step_active in {% for s in steps %}<span class="{{ 'active' if step_active else 'idle' }}">) produced patches=[] and stale HTML. Fix: extract_from_nodes now extracts non-literal variables from all three InlineIf expressions.
  • Nested {% include %} now propagates wildcard dep to enclosing wrappers (#783) — Rust partial renderer reused the cached fragment of an {% if %} / {% for %} / {% with %} wrapping a nested {% include %}, because extract_from_nodes treated Include as having no variable references. When the included template referenced a context key that changed (e.g. {{ field_html.first_name|safe }}), the wrapper's dep set ({current_step_name}) did not intersect changed_keys ({field_html}), needs_render returned false, the cached HTML was reused, and the text-region fast-path compared byte-identical old/new HTML → patches=[] with diff_ms: 0. Manifested with deeply-nested WizardMixin templates ({% extends %} → {% block %} → {% if current_step_name == "..." %} → {% include "step_*.html" %}). Fix: extract_from_nodes now injects "*" into the variables map when it encounters a nested Include or CustomTag/BlockCustomTag during its walk, so wrapper deps include the wildcard and those nodes are always re-rendered. (crates/djust_templates/src/parser.rs)
  • _force_full_html now calls set_changed_keys so Rust partial renderer re-renders (#783) — When _force_full_html was set, _sync_state_to_rust() cleared prev_refs to force all context to Rust, but the set_changed_keys call was gated by if prev_refs which evaluated to False after clearing. Rust's partial renderer saw no changed_keys, fell back to full render with empty changed_indices, and the text-region fast-path compared identical old/new HTML → zero patches. Fix: set_changed_keys is now called when _force_full_html is set regardless of prev_refs.

Docs

  • ROADMAP correction: temporary_assigns is already implemented — The v0.5.0 ROADMAP entry claiming temporary_assigns was "completely absent from djust today" was inaccurate. The feature has shipped in earlier releases (LiveView._initialize_temporary_assigns / _reset_temporary_assigns, wired into the render cycle and excluded from change tracking). This PR adds a dedicated regression test (tests/unit/test_temporary_assigns.py) — prior coverage was indirect — and strikes through the ROADMAP entry.

Tests

  • Regression coverage for temporary_assignstests/unit/test_temporary_assigns.py covers reset-after-render semantics, default-value cloning per type (list / dict / set / scalar), idempotent initialization, pre-existing-attribute preservation, instance-level override, and the empty-mapping no-op path.
  • Unit tests for assign_async / AsyncResulttests/unit/test_assign_async.py (18 tests) covers state-flag invariants, frozen dataclass immutability, pending-is-set-immediately, success & failure propagation, multi-concurrent scheduling, cancellation interop with cancel_async, sync and async loaders, args/kwargs forwarding, and the generation-counter / stale-loader regression cases added in #793.
  • Unit tests for {% dj_suspense %}tests/unit/test_suspense.py (12 tests) covers ok → body, loading → fallback, failed → error-div, HTML-escaped error messages, no-await= passthrough, unknown / non-AsyncResult refs defaulting to loading, default spinner, Django template fallback, template-error graceful degradation, nesting, and whitespace-tolerant comma-separated lists.
  • Regression suite for |safe HTML blob diff (#783)tests/test_rust_vdom_safe_diff_783.py exercises the WizardMixin-style pattern where field_html is derived in get_context_data() from an instance attribute. Covers dict reassignment, in-place nested mutation, the _force_full_html codepath, an {% if %} branch swap, a {% extends %}/{% block %} inheritance chain, and the exact downstream-consumer-style {% extends %} + {% if %} + {% include %} structure that originally exhibited the bug. All variants assert non-empty VDOM patches on state change.
  • Dep-extractor hardening (#783 follow-up, P0) — Three-part hardening against silent dep-drop regressions in crates/djust_templates/src/parser.rs::extract_from_nodes:
    • Rust unit tests for extract_per_node_deps — table-driven assertions on representative AST shapes (simple Variable, If-wrapping-Include, For with tuple unpacking, With + body, InlineIf condition, nested For, Block recursion, plain Text). Explicit "*" wildcard membership checks for nested Include / CustomTag shapes.
    • Node variant exhaustiveness checksample_for_coverage exhaustive match on Node::* + sample_nodes() constructor + NO_VARS_VARIANTS allow-list. Any new Node variant fails to compile until the match is updated, and at runtime every non-allow-listed variant must produce a non-empty dep set (real vars or "*" wildcard). Makes silent dep-drops on future additions impossible.
    • Partial-render correctness harness (Python)TestPartialRenderCorrectness in tests/test_rust_vdom_safe_diff_783.py. Byte-equality oracle: for each of 6 wrapper shapes (no-wrapper, {% if %}, {% for %}, {% with %}, full #783 extends/if/include/safe chain, InlineIf-in-for), renders a mutation via the normal partial-render path then re-renders the same mutation with the Rust fragment cache cleared (clear_fragment_cache()) as a control. Any dep-miss that causes partial render to reuse a stale cached fragment diverges from the control and fails.
  • New PyO3 method DjustLiveView.clear_fragment_cache (test-only) (crates/djust_live/src/lib.rs) — clears node_html_cache, last_html, fragment_text_map, text_node_index while preserving last_vdom so the diff baseline is unchanged. Exclusively supports the partial-render correctness harness above; not intended for application use.

[0.5.0rc2] - 2026-04-20

Added

  • Bootstrap 4 CSS framework adapter — New Bootstrap4Adapter for projects using Bootstrap 4 (NYC Core Framework, government sites, legacy projects). Set DJUST_CONFIG = {"css_framework": "bootstrap4"}. Includes proper custom-select, custom-control-* classes for checkboxes/radios, and form-group wrappers. See docs/website/guides/css-frameworks.md.

  • Dedicated radio button classes — Radio buttons now use radio_class, radio_label_class, and radio_wrapper_class config keys (with fallback to checkbox classes). Both Bootstrap 4 and 5 configs define radio-specific classes.

  • Select widget class supportChoiceField with Select widget uses select_class config key (e.g., custom-select for BS4, form-select for BS5) instead of the generic field_class.

  • Theme-to-framework CSS bridge — New {% theme_framework_overrides %} template tag generates <style> overrides that map djust theme variables (--primary, --border, etc.) onto the active CSS framework's selectors (.btn-primary, .form-control, .alert-*, etc.). Switching themes now automatically re-styles Bootstrap 4/5 components.

    See docs/website/guides/css-frameworks.md.

Fixed

  • Derived container context values now tracked by value equality (#774) — The Rust state sync used id() comparison for all non-immutable context values, which is unreliable for containers (dict, list, tuple) due to CPython address reuse after GC. Derived values like current_step = wizard_steps[step_index] could be missed when the handler only changed step_index, causing Rust to render stale HTML. Fix: containers are now compared by value equality (like immutables already were), with previous values cached in _prev_context_containers. The optimization is preserved — unchanged containers are still skipped.

[0.5.0rc1] - 2026-04-19

Added

  • Package consolidation: all 5 runtime packages folded into djust — One install, one version, one CHANGELOG. pip install djust stays lean; pip install djust[all] gets everything.
    • Phase 1+2: djust-auth + djust-tenants → core (#770) — djust-auth (879 LOC) merged into python/djust/auth/ package with lazy imports. djust-tenants missing modules (audit, middleware, managers, models, security) merged into existing python/djust/tenants/. Both are core — no extras needed. 27 new tests.
    • Phase 3: djust-admindjust[admin] (#771) — 3,878 LOC merged into python/djust/admin_ext/ (avoids collision with django.contrib.admin). Views, forms, adapters, plugins, decorators, template tags, 7 HTML templates. 40 new tests.
    • Phase 4: djust-themingdjust[theming] (#772) — 49,105 LOC merged into python/djust/theming/. CSS theming engine, design tokens, 96 HTML templates, 9 static files, management command (djust_theme), 4 template tag modules, gallery sub-package. 749+ tests.
    • Phase 5: djust-componentsdjust[components] (#773) — ~100K LOC merged into python/djust/components/. 170+ UI component classes, 6 template tag modules, management command (component_gallery), descriptors, mixins, rust_handlers.py. Extra deps: markdown>=3.0, nh3>=0.2.

[0.4.5rc2] - 2026-04-18

Added

  • AI observability module: djust.observability — DEBUG-gated, localhost-only HTTP endpoints that give external tooling (like the djust Python MCP and djust-browser-mcp) live visibility into framework state without in-process coupling. Ships as seven endpoints under /_djust/observability/: health, view_assigns, last_traceback, log_tail, handler_timings, sql_queries, reset_view_state, eval_handler. Each pairs with a matching MCP tool. Security model mirrors django-debug-toolbar (DEBUG=True + LocalhostOnlyObservabilityMiddleware). Requires path("_djust/observability/", include("djust.observability.urls")) in the project urls.py.

  • get_view_assigns — Real server-side self.* state of the mounted LiveView for a given session. Complements browser-mcp's client-only djust_state_diff with the source of truth. Per-attr fallback tags non-serializable values with {_repr, _type} rather than an all-or-nothing blanket.

  • get_last_traceback — Ring-buffered (50) exception log populated from handle_exception(). Replaces "can you paste the terminal?" for 80% of blind-debugging cases.

  • tail_server_log — Ring-buffered (500) Django/djust log records with since_ms + level filters. djust.* captured at DEBUG+, django.* at WARNING+. See docs/website/guides/mcp-server.md.

  • get_handler_timings — Per-handler rolling 100-sample distribution (min/max/avg/p50/p90/p99). Reuses existing timing["handler"] measurements; no extra perf counters.

  • get_sql_queries_since — Per-event SQL capture via connection.execute_wrappers. Queries are tagged with (session_id, event_id, handler_name) + stack_top filtered to skip framework frames.

  • reset_view_state — Replay view.mount() on a registered instance. Clears public attrs, re-invokes mount(stashed_request, **stashed_kwargs). Useful between fixture replays.

  • eval_handler — Dry-run a handler against a live view's current state. Returns {before_assigns, after_assigns, delta, result}. v2 dry_run=True installs a DryRunContext that blocks Model.save/delete, QuerySet.update/delete/bulk_create/bulk_update, send_mail/send_mass_mail, requests.*, and urllib.request.urlopen — first attempt raises DryRunViolation and the response surfaces {blocked_side_effect}. dry_run_block=False records without blocking. Process-wide lock serializes dry-runs.

  • find_handlers_for_template(template_path) in djust MCP — Cross-references a template file against every view that uses it, returning dj-* handlers wired in the template and the diff against view handler methods. Catches dead bindings at author time (complements djust-browser-mcp's runtime find_dead_bindings). See docs/guides/djust-audit.md.

  • seed_fixtures(fixture_paths) in djust MCP — Subprocess wrapper around manage.py loaddata for regression-fixture DB setup.

    See docs/website/guides/mcp-server.md.

Fixed

  • hotreload: suppress empty-patch broadcasts on unrelated file changes (#763) — When a Python file changes that doesn't affect the currently-mounted view, re-render produces zero patches. The old code still broadcast ~14 KB (empty patches + full _debug state dump) to every connected session. Early-return when hotreload=True AND patches==[]. Non-hot-reload empty patches still sent (loading-state clear ack needed).
  • client.js: guard 38 unguarded console.log calls (#761) — Per djust/CLAUDE.md rule, no console.log without if (globalThis.djustDebug) guard. Introduced a djLog helper in 00-namespace.js and replaced bare console.logdjLog across 12 client modules. console.warn/console.error untouched (real problems stay visible in prod).
  • Observability DryRunContext._uninstall logs setattr failures (#759) — Silent except Exception: pass meant the process could run indefinitely with a wrapped Model.save if uninstall partially failed — catastrophic for a dev server. Replaced with a logger.warning so the failure is observable.

Changed

  • djust.observability + eval_handler v2 — Side-effect blocking now covers QuerySet bulk writes (#758): QuerySet.update/delete/bulk_create/bulk_update are patched alongside Model.save/delete, so a handler that does Model.objects.filter(...).update(...) correctly raises DryRunViolation instead of silently committing.
  • Observability dry_run tests tightened (#760) — Two tests claimed to verify the record-but-allow contract but only checked detection. Now use unittest.mock to assert the original callable was actually invoked (call_count == 1) alongside the violation-recorded assertion.

[0.4.5rc1] - 2026-04-17

Changed

  • Text-region fast path now fires for {% extends %} templates — The scanner that builds the VDOM text-node position index used to process the full pre-hydration HTML, but the VDOM is rooted at [dj-root]. On templates extending a base (with <title>, meta tags, scripts outside dj-root), the scanner counted text runs in <head> and trailing <footer>/<script> siblings that the VDOM didn't have — the count mismatched, the index was discarded, and every event fell through to a full html5ever parse (~10ms on the djust.org /examples/ page). Now the scanner is restricted to the dj-root element's interior via a balanced-tag walker. Rust render drops from ~14ms → ~2.8ms on extends templates; browser E2E (production, DEBUG=False) drops from 30ms → ~25ms avg, 18ms min.

    See docs/website/core-concepts/templates.md.

  • Text-region VDOM fast path — Extends the existing text-fast-path to handle changes that differ only in a text span, even when the surrounding fragment contains tags. Computes byte-level common prefix/suffix on pre-hydration HTML; if the divergence is a single tag-free text run, locates the owning VDOM text node via a pre-built positional index (binary search on (html_start, html_end, path, text, djust_id) entries, built once per full-parse render and kept in sync through fast-path events by shifting downstream entries by the byte delta). Patches in place and skips html5ever entirely. For a counter click inside a {% for %} loop on a 309KB page, Rust render drops from ~12ms to ~2.7ms. UTF-8 safe (snaps to char boundaries), handles <pre>/<code>/<textarea> whitespace preservation and <script>/<style> raw-text element bodies correctly, bails to full parse on entity-offset mismatches.

  • parse_html_fragment(html, context_tag) — New public entry point in djust_vdom that uses html5ever's parse_fragment with a parent-element context. Enables parsing isolated HTML fragments with correct tokenization for context-sensitive elements (<tr>, <td>, <option>), without resetting the dj-id counter. Scaffolding for future structural-fragment fast paths.

  • collect_vdom_text_nodes now skips comment nodes — Previously collected <!--dj-if--> placeholders into the text-node list, shifting every subsequent ordinal by one and breaking any position-based patching. Text and comment VNodes both carry text, so an explicit is_text() filter was needed.

  • Partial template rendering (#737) — Per-node dependency tracking at template parse time. On re-render, only template nodes whose context variable dependencies changed are re-rendered; unchanged nodes reuse cached HTML. For a single-variable change on a page with 50 template nodes, template render drops from ~1.4ms to ~0.1ms. Changed keys are passed from Python to Rust via set_changed_keys(), which merges across multiple sync calls. {% include %} and custom tags always re-render (wildcard dependency).

  • {% extends %} inheritance resolution caching — Templates using {% extends %} now participate in partial rendering. Inheritance is resolved once via OnceLock<ResolvedInheritance> on the Template struct (shared via TEMPLATE_CACHE). Final merged nodes and their deps are cached, so subsequent renders skip both chain building and static parent nodes. Combined with partial rendering, extends templates go from full re-render (~14ms Rust) to partial render of changed nodes only (~0.02ms Rust).

  • Text-only VDOM fast path — When all changed template fragments are plain text (no HTML tags), skip both html5ever parsing and VDOM diffing entirely. The old VDOM is mutated in-place via a fragment→text-node map built on first render, and SetText patches are produced directly. For counter-style updates: parse phase drops from ~12ms to ~0.001ms.

  • Block flattening for partial rendering{% block %} nodes left by Django's template engine are flattened to expose each child as a separate fragment. This enables the text fast path to activate on pages using {% extends %} where Django resolves blocks.

  • Faster change detection_snapshot_assigns uses identity + shallow fingerprints (id, length, content hash for list-of-dicts) instead of copy.deepcopy. Framework-internal keys (csrf_token, kwargs, temporary_assigns, DATE_FORMAT, TIME_FORMAT) and auto-generated _count keys are excluded from set_changed_keys to avoid spurious re-renders.

  • Optimized VNode parser — Pre-sized attribute HashMap, eliminated redundant to_lowercase() call, removed form element debug output.

Fixed

  • Derived immutable context values no longer go stale on partial re-render_sync_state_to_rust previously skipped id()-based change detection for immutable types (int/str/bool/bytes) to avoid false positives from Python's int cache, which meant derived values computed in get_context_data (e.g. completed_count = sum(...), total_count = len(...)) were never synced to Rust when their sources changed. Partial rendering would then reuse the cached HTML for template nodes depending on those values, leaving counters stale after add/toggle/delete. Fixed by tracking previous VALUES for immutable keys and comparing by equality. Regression tests in test_changed_tracking.py::TestDerivedImmutableSync.

  • VDOM input value leak on name change — When the patcher morphs an input into a different field (e.g., wizard step 1 name → step 2 email), the old field's typed value no longer leaks into the new field. Both morphElement and SetAttr patches now clear .value when the name attribute changes.

  • In-place dict mutation detection_snapshot_assigns now fingerprints list contents (id + dict values hash) to detect mutations like todo['completed'] = True that don't change the list's id or length. Falls back to id-only for unhashable values.

  • Derived context value detection — When _changed_keys is set, the sync also checks non-immutable context values by id() to catch derived values (e.g., products from _products_cache) that change via private attributes.

[0.4.4] - 2026-04-15

Changed

  • Remove double updateHooks()/bindModelElements() scanning — These were called in both applyPatches() and reinitAfterDOMUpdate(), scanning the full DOM twice per patch cycle. Removed from applyPatches(). Saves ~5ms per event.

  • Delegated scoped listeners (dj-window-, dj-document-) — Replaced querySelectorAll('*') full DOM scan with a registry-based delegation pattern. Scoped elements are scanned once at mount time and registered in a Map. Event listeners on window/document dispatch to the registry. Handles dotted attribute variants (dj-window-keydown.escape).

  • Use orjson.loads() for patch JSON parsing — 2-3x faster than stdlib json.loads() when orjson is installed. Falls back gracefully.

  • Gate debug payload behind panel open stateget_debug_update() (dir + getattr + json.dumps per attribute) only runs when the debug panel is actually open, not on every event in DEBUG mode. Saves ~2-5ms per event. Panel sends debug_panel_open/debug_panel_close WS messages on toggle.

[0.4.4rc1] - 2026-04-15

Fixed

  • VDOM patch path traversal skips regular HTML comments (#729) — The JS patcher was counting all HTML comment nodes during path traversal, but the Rust VDOM parser only preserves <!--dj-if--> placeholders. This caused every page with HTML comments in dj-root to fail VDOM patching and fall back to full HTML recovery.

  • Scroll to top on dj-navigate live_redirecthandleLiveRedirect() now scrolls to the top of the page (or to anchor if URL has a hash) after pushState.

Changed

  • Event delegation replaces per-element binding (#730)bindLiveViewEvents() no longer scans the DOM after every VDOM patch. Instead, one listener per event type is installed on the dj-root element via delegation (e.target.closest('[dj-click]')). This reduces client-side post-patch handling from ~56ms to ~30ms on large pages. Per-element rate limiting preserved via WeakMap.

Added

  • Per-phase Rust timing in render_with_diff() (#730) — Instrumentation measuring template render, html5ever parse, VDOM diff, and HTML serialization. Exposed to Python via get_render_timing() and propagated to WebSocket response performance metadata.

[0.4.3] - 2026-04-14

Fixed

  • {% csrf_token %} no longer renders poisoned CSRF_TOKEN_NOT_PROVIDED placeholder (#696) — The Rust template engine now renders an empty string when no CSRF token is in context (instead of a placeholder that poisoned client.js's CSRF lookup). Python LiveView _sync_state_to_rust() now injects the real token from get_token(request). Three-layer defense-in-depth fix merged as PR #708.

  • HTTP fallback POST no longer replaces page with logged-out render (#705) — The POST handler now applies _apply_context_processors() before render_with_diff() so auth context (user, perms, messages) is available during re-render. Context processor cleanup uses _processor_context() context manager for guaranteed cleanup. Merged as PR #710 + #714 + #721.

  • Rust |date and |time filters honor Django DATE_FORMAT/TIME_FORMAT settings (#713) — New apply_filter_with_context() checks the template context for format settings when no explicit format argument is given. Python injects Django settings into the Rust context during _sync_state_to_rust(). Merged as PR #714.

  • Rust |date filter now works on DateField values (#719) — The |date filter previously only parsed RFC 3339 datetime strings. DateField values (bare dates like "2026-03-15") are now parsed via a NaiveDate fallback pinned to midnight UTC. Merged as PR #720.

  • CSRF token value HTML-escaped in Rust renderer (#722) — The CSRF hidden input now uses the shared filters::html_escape() utility (escaping &, ", <, >, and single quotes) instead of a manual .replace() chain that missed single quotes. Defense-in-depth. Merged as PR #727.

  • Bare except: pass in CSRF injection now logs a warning (#716) — The CSRF token injection in _sync_state_to_rust() previously swallowed all exceptions silently. Now logs via djust.rust_bridge logger with exc_info=True. Merged as PR #721.

Changed

  • Context processor cleanup refactored to _processor_context() context manager (#717) — Replaced the manual try/finally in the HTTP fallback POST handler with a reusable @contextmanager that guarantees cleanup of temporarily injected view attributes. Merged as PR #721 + #727.

  • Pre-existing test fixestest_debug_state_sizes corrected for json.dumps(default=str) behavior and \uXXXX escaping. navigation.test.js suppresses happy-dom/undici WebSocket mock dispatchEvent incompatibility.

Added

  • Python integration tests for DATE_FORMAT settings injection (#718) — 4 tests verifying _sync_state_to_rust injects DATE_FORMAT/TIME_FORMAT from Django settings. Merged as PR #721.

  • Negative tests for |date filter invalid input (#725) — 4 Rust tests covering invalid dates, non-date strings, empty strings, and partial dates (filter returns original value per Django convention). Merged as PR #727.

    See docs/guides/live-input.md.

  • format_date() doc comment documenting Django compatibility (#726) — Documents supported input formats (RFC 3339, YYYY-MM-DD) and unsupported types (epoch ints, locale strings). Merged as PR #727.

[0.4.2] - 2026-04-13

Fixed

  • Derived context vars synced when parent instance attr mutated in-place (#703)_sync_state_to_rust() now collects id()s of all sub-objects reachable from changed instance attrs and includes any derived context var whose id() appears in that set. Previously, context vars computed in get_context_data() that returned sub-objects of a mutated dict (e.g., wizard_step_data.get("person", {})) were skipped because their id() was unchanged, causing templates to render stale data. Depth-capped at 8 with cycle detection. 9 new regression tests.

  • as_live_field() now respects widget.input_type override for type attribute (#683 re-open) — The initial #683 fix merged widget.attrs but type was still ignored because Django moves type= from attrs into widget.input_type during widget __init__. _get_field_type() now checks widget.input_type against the widget class's default and uses the override when they differ (e.g. TextInput(attrs={"type": "tel"}) sets input_type="tel"). 4 new regression tests covering type="tel", type="url", type="search", and the default type="text" fallback.

Added

  • LiveComponent events now propagate to parent LiveView waiters (ADR-002 Phase 1b/1c follow-up) — Closes the "known limitation" documented in the v0.4.2 tutorials guide: await self.wait_for_event("foo") on a LiveView now resolves when the matching handler fires on an embedded LiveComponent, not just when it fires on the view itself. Without this, a TutorialStep(wait_for="submit", ...) where submit is a handler on a child FormComponent would silently stall forever — the parent view's waiter would never resolve and the tour would hang. The fix is in the WebSocket consumer's handle_event component-event branch: after the component handler runs, the consumer now calls self.view_instance._notify_waiters(event_name, notify_kwargs) with the handler's kwargs + an injected component_id key, mirroring the notification that already happened in the main LiveView branch from Phase 1b. The component_id injection means apps can use the waiter's predicate argument to disambiguate events fired from multiple component instances: wait_for_event("submit", predicate=lambda kw: kw.get("component_id") == "project_form"). A notification failure is caught and logged via the djust.websocket logger so a buggy waiter/predicate can't break the component handler's observable behavior — the component's state mutations always happen even if the waiter notification raises. 5 new regression tests in python/tests/test_waiter_component_propagation.py covering: component event resolves parent waiter, component_id is injected into notify kwargs so predicates can filter by source, multiple parent waiters for the same event all resolve (fan-out), the non-component branch still notifies parent waiters (regression guard for the Phase 1b path), and a raising _notify_waiters is logged-and-swallowed rather than propagating. docs/website/guides/tutorials.md Limitations section updated to document the new behavior with a component_id predicate example.

Documentation

  • Tutorial bubble must be placed outside dj-root (#699) — If the {% tutorial_bubble %} tag is placed inside the dj-root container, morphdom recovery (which replaces the entire dj-root content on patch failure) destroys the bubble mid-tour, causing it to silently disappear. The tutorials guide now has a dedicated "Bubble Placement" section explaining the requirement, why it exists, and correct/incorrect examples. The simplest-possible example at the top of the guide is updated to show the bubble outside dj-root. The tutorial_bubble template tag docstring is also updated with this requirement.

  • data-* attribute naming convention documented in Events guide (#623) — How data-foo-bar on an HTML element maps to foo_bar in the event handler's kwargs was undocumented. The Events guide now has a dedicated "Data Attribute Naming Convention" section covering: the dash-to-underscore rule, client-side type-hint suffixes (:int, :float, :bool, :json, :list), server-side Python type-hint coercion, the dj-value-* alternative, which internal data-* attributes are excluded, and a quick-reference table.

Changed

  • System checks T002, V008, C003 now suppressible via DJUST_CONFIG (#603) — These three informational checks fire on every manage.py invocation and are noisy for projects that deliberately don't use the checked features (daphne, explicit dj-root, non-primitive mount state). A new suppress_checks config key in DJUST_CONFIG (or LIVEVIEW_CONFIG) accepts a list of check IDs to silence: DJUST_CONFIG = {"suppress_checks": ["T002", "V008", "C003"]}. Both short IDs ("T002") and fully-qualified IDs ("djust.T002") are accepted, case-insensitive. Only the informational/advisory variants are suppressed — the C003 Warning (daphne misordered) still fires because it indicates a real misconfiguration. 7 new tests for the suppression mechanism.

  • release-drafter/release-drafter v6 → v7 + drop pull_request trigger — v7 validates target_commitish against the GitHub releases API and rejects refs/pull/<n>/merge refs, which is what github.ref resolves to under a pull_request trigger. v6 silently tolerated this; v7 does not, causing every PR to fail with Validation Failed: target_commitish invalid. The fix is to drop the pull_request trigger — release-drafter is designed to track changes that have landed on the release branch, not comment on in-flight PRs, so push: branches: [main] is the right fit. Aligns with how Phoenix, Elixir, GitHub CLI, and other major projects wire release-drafter. Resolves the v7 bump that was deferred out of the v0.4.2 dependabot batch (#680).

  • Dependency batch carry-over (v0.4.2) — Drains the dependabot backlog that was held behind the v0.4.1 release. Single consolidated PR so one CI run catches any inter-dep interactions:

    • npm: vitest / @vitest/ui / @vitest/coverage-v8 4.0.18 → 4.1.4 (patches + new test runner features), jsdom 29.0.1 → 29.0.2, happy-dom 20.8.4 → 20.8.9. Full JS suite remains green (1111 tests).
    • Cargo: tokio 1.50 → 1.51 (workspace), uuid 1.22 → 1.23, proptest 1.10 → 1.11 (djust_vdom), indexmap 2.13.0 → 2.14.0 (transitive pickup via cargo update). cargo check --workspace clean; cargo test -p djust_vdom passes all 42 proptest-driven tests on the new 1.11 runtime.
    • GitHub Actions: actions/github-script v8 → v9 (two workflows), astral-sh/setup-uv v6 → v7 (test workflow). Workflow syntax unchanged.
    • Intentionally deferred: html5ever 0.36 → 0.39 is a 3-minor-version jump that requires a matching markup5ever_rcdom 0.39 release which has not yet been published to crates.io (only git snapshots exist in the html5ever workspace). Using git deps in our published workspace would break cargo publish and leak unreleased upstream state, so this stays deferred until upstream publishes. release-drafter/release-drafter v6 → v7 was also deferred out of this chore batch because of a target_commitish validation incompatibility — shipped as a separate follow-up PR alongside this one.

    Closes 13 open dependabot PRs as superseded (#581, #582, #604, #606, #607, #609, #615, #616, #644, #645, #646, #647, #648).

Fixed

  • @background natively supports async def handlers (#697) — The @background decorator now detects asyncio.iscoroutinefunction and creates a native async closure so _run_async_work can await it directly on the event loop instead of routing through sync_to_async. The fragile inspect.iscoroutine(result) workaround from #692 is kept as a legacy fallback. 5 new regression tests.

  • flush_push_events() resolves callback dynamically on WS reconnect (#698)PushEventMixin.flush_push_events() now resolves the flush callback via self._ws_consumer._flush_push_events at call time instead of relying on a stored _push_events_flush_callback that was only wired during initial mount. After a WebSocket reconnect the view instance is restored from session but the stored callback was stale. The dynamic lookup always finds the current consumer. Legacy stored callback kept as fallback. 7 new tests.

  • push_commands-only handlers auto-skip VDOM re-render (#700) — Handlers that only call push_commands() / push_event() without changing public state no longer trigger a VDOM re-render. The _snapshot_assigns deep-copy comparison could report false positives for views with non-copyable public attributes (querysets, file handles) because sentinel objects never compare equal. A new identity-based check (id() comparison before/after) detects whether any public attribute was actually rebound and auto-sets _skip_render = True when push events are pending but no state changed. 5 new tests.

  • System check V010 detects wrong TutorialMixin MRO ordering at startup (#691) — Django's View.__init__ does not call super().__init__(), so writing class MyView(LiveView, TutorialMixin) silently skips TutorialMixin's initialisation. A new djust.V010 system check scans all LiveView subclasses at startup and emits an Error with a clear fix hint when TutorialMixin appears after a View-derived base in the class declaration. Suppressible via DJUST_CONFIG = {"suppress_checks": ["V010"]}. 5 new tests. Tutorials guide updated with correct ordering.

  • @background async def handlers now execute correctly (#692)@background wraps handlers in a sync closure; when the handler is async def, the closure returned an unawaited coroutine and the handler body never ran. The fix in _run_async_work (already on main via workaround) detects coroutine returns and awaits them. 11 new regression tests in test_background_async.py verify both sync and async handlers execute their bodies.

  • push_commands in @background tasks now flush mid-execution (#693) — Push events queued by push_commands inside a @background handler only reached the client when the entire task completed. The _flush_pending_push_events callback mechanism (already on main) lets TutorialMixin and other background handlers flush events immediately. A new public await self.flush_push_events() method on PushEventMixin provides the same capability to any @background handler. 7 new tests in test_push_flush_background.py.

  • get_context_data no longer includes non-serializable class attributes (#694) — The MRO walker in ContextMixin.get_context_data() added class-level attributes (like tutorial_steps) to the template context. Non-JSON-serializable values were silently converted to their str() repr, corrupting state on subsequent events. The fix skips class-level attributes that fail a JSON serialisability probe. Additionally, TutorialMixin now stores steps as _tutorial_steps (private) with a read-only tutorial_steps property, so they are excluded by both the _ prefix convention and the serialisability check. 14 new tests.

  • Debug panel SVG attributes no longer double-escaped (#613) — SVG attributes like viewBox and path d in the debug toolbar were rendered garbled because the Rust VDOM's to_html() method HTML-escaped text content inside <script> and <style> elements. Per the HTML spec, these are "raw text elements" whose content must be emitted verbatim — escaping & to &amp; or < to &lt; corrupts JavaScript/CSS code and causes double-escaping when the HTML is round-tripped through the VDOM pipeline (parse with html5ever which decodes entities, then re-serialize with to_html() which re-encodes them). The fix adds an in_raw_text flag to the internal _to_html() serializer that propagates through <script>/<style> children, skipping html_escape() for their text nodes. SVG attribute values in templates (which don't contain HTML special characters) were already correct but now have explicit regression tests. 4 new Rust unit tests, 3 new Rust integration tests (script/style/SVG roundtrip), 3 new Python regression tests (JS source validation, JSON injection check, VDOM roundtrip), and 3 new JS tests (tab icon SVGs, path d attributes, header button SVGs all verified in DOM).

  • form.cleaned_data Python types no longer serialize to null (#628)datetime.date, datetime.datetime, datetime.time, Decimal, and UUID values in form.cleaned_data stored in public view state are now properly serialized to their JSON representations (ISO strings, floats, strings) instead of silently becoming null. Both the DjangoJSONEncoder and normalize_django_value() already handled these types; 10 new regression tests confirm the behavior.

  • set() is now JSON-serializable as public state (#626) — Storing a Python set() or frozenset() in public view state no longer crashes json.dumps. Sets are serialized as sorted lists (falling back to unsorted when elements aren't comparable). Both DjangoJSONEncoder.default() and normalize_django_value() now handle set/frozenset. 11 new regression tests.

  • dict state no longer corrupted to list after Rust state sync (#612) — Round-tripping state through the Rust MessagePack serialization boundary could corrupt dict values into list because #[serde(untagged)] on the Value enum let rmp_serde match a msgpack map against the List variant before trying Object. The fix replaces the derived Deserialize with a custom visitor-based implementation that uses the deserializer's type hints (visit_map vs visit_seq) to correctly distinguish maps from arrays. 4 new Rust regression tests + 1 Python end-to-end msgpack round-trip test.

  • as_live_field() now merges widget.attrs into rendered HTML (#683) — The as_live_field() method (and {% live_field %} tag) dropped any attributes defined on a Django widget's attrs dict — type="email", placeholder, pattern, min/max, custom data-*, and any other HTML attributes were silently lost. The fix adds _merge_widget_attrs() to BaseAdapter, called from _render_input, _render_checkbox, and _render_radio, which merges field.widget.attrs into the output attributes with djust-specific keys (dj-change, name, class, etc.) taking precedence over widget defaults. Boolean False/None values in widget attrs are filtered out to avoid rendering disabled="False". 17 new regression tests in python/tests/test_live_field_widget_attrs.py covering: EmailInput placeholder/type, pattern/min/max/step/title, djust attrs override clashing widget attrs, empty widget attrs, textarea rows/cols, checkbox data-attrs, radio data-attrs on each option, select data-attrs, and boolean True/False handling.

  • VDOM patcher guards against text nodes for 5 patch types (#622) — The VDOM diff patcher called setAttribute(), removeAttribute(), appendChild(), removeChild(), and replaceChild() on #text nodes, which don't implement these methods. This crashed conditional rendering whenever a text node sat where the patcher expected an element (common in {% if %} blocks that switch between text and element content). The fix adds an isElement(node) guard at the top of each of the five patch-type branches in 12-vdom-patch.js — when the target is a non-element node (text, comment, CDATA), the patch is skipped gracefully instead of throwing. 4 new JS tests in tests/js/vdom_patch_errors.test.js covering setAttribute, removeAttribute, appendChild, and replaceChild on text nodes.

  • Autofocus handling on dynamically inserted elements (#617) — Dynamically inserted <input autofocus> elements didn't receive focus after a VDOM patch because the browser only honours the autofocus attribute on initial page load. The patcher now detects autofocus on newly inserted elements after each patch cycle and calls .focus() explicitly. 4 new JS tests in tests/js/vdom-autofocus.test.js covering single autofocus, multiple elements (last wins), elements without autofocus ignored, and no-op when no autofocus elements are present.

  • Private _ attributes preserved across events and reconnects (#627, #611) — Two related state-management bugs caused any attribute starting with _ (the documented convention for private/internal state) to be silently wiped. The root cause was that session save used the output of get_context_data(), which by design strips _-prefixed attributes. For #627, every WebSocket event round-trip lost private state because _save_state_to_session() persisted only public context. For #611, the pre-rendered WS reconnect path restored session state but never included private attributes set during the HTTP GET mount. The fix adds two helpers — _get_private_state() (collects all _-prefixed instance attrs that aren't dunder or in the base-class exclusion set) and _restore_private_state(state_dict) — and wires them into _save_state_to_session() (now persists private state under a _private_state session key) and _load_state_from_session() / the reconnect path in RequestMixin._restore_session_state() (restores private attrs before the view resumes). 20 new regression tests in python/tests/test_private_attr_preservation.py covering: private attrs survive event dispatch, survive reconnect, survive multiple sequential events, coexist with public attrs, handle None/complex/nested values, are excluded for dunder attrs, are excluded for base-class internals, and round-trip through session save/load.

  • Layout flash on pre-rendered mount: defer reinitAfterDOMUpdate via requestAnimationFrame (#619, fixes #618) — Carry-over bugfix from v0.4.1. When a page is pre-rendered via HTTP GET, the WebSocket mount used to call reinitAfterDOMUpdate() synchronously right after stamping dj-id attributes onto the existing DOM. That synchronous call triggered a full DOM traversal for event binding, which forced the browser to recalculate layout mid-paint — and on pages with large pre-rendered elements (e.g. big dashboard stat values) the elements briefly rendered at the wrong size before settling, producing a visible layout-flash on every initial load. The fix moves the post-mount block (reinit + _mountReady flag + form recovery + auto-recover) into a runPostMount closure and schedules it via requestAnimationFrame(runPostMount) when available, falling back to a synchronous call when requestAnimationFrame is unavailable (JSDOM tests, exotic non-browser environments). Event binding now happens after the browser finishes its current paint, eliminating the flash entirely. The ordering invariant (reinit → _mountReady → form recovery) is preserved inside the closure so dj-mounted handlers and recovered form inputs still see bound event listeners. The non-prerendered data.html innerHTML-replace branch is unchanged — it already invalidates layout via the full DOM swap so there's no pre-paint to protect. 8 new regression tests in tests/js/mount-deferred-reinit.test.js asserting: the rAF wrapper is present, the synchronous fallback is preserved, the closure is named runPostMount for stable debugging, reinitAfterDOMUpdate() runs before _mountReady inside the closure, _mountReady is set inside the closure (not synchronously), form recovery runs only on reconnect inside the closure, the non-prerendered branch calls reinit synchronously, and exactly one call-site of reinitAfterDOMUpdate() exists in the skipMountHtml branch (so a refactor that reintroduces the sync call would immediately flip red). Closes #619 as superseded and closes the original #618 bug report.

  • Scaffolded projects now default DEBUG=False and generate .env.example (#637) — Carry-over bugfix from v0.4.1. Previously, python -m djust startproject mysite and python -m djust new mysite both generated a settings.py with DEBUG = True and ALLOWED_HOSTS = ["*"] as hardcoded literals. A developer who deployed the scaffolded output without remembering to flip those values ran production with full stack traces, the django-insecure-<random> default SECRET_KEY, and a wildcard host allowlist — the exact footgun that A001 (DEBUG enabled) and A014 (ALLOWED_HOSTS too permissive) flag in djust_audit. Now both scaffold paths (cli.py's cmd_startproject and the higher-level djust.scaffolding.generator.generate_project) emit DEBUG = os.environ.get("DEBUG", "False").lower() in ("true", "1", "yes") and ALLOWED_HOSTS = [host.strip() for host in os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") if host.strip()] — unconfigured deployments fail safe. The scaffold also writes a .env.example template alongside .gitignore (which already ignores .env) so local development picks up developer-friendly values via cp .env.example .env + whatever .env loader the developer uses. The .env.example includes DEBUG=True, a freshly-generated SECRET_KEY token (via secrets.token_urlsafe(50)), and ALLOWED_HOSTS=localhost,127.0.0.1 so the local experience hasn't changed. 4 new regression tests in python/tests/test_cli_scaffold.py asserting: DEBUG = True is no longer literal, DEBUG reads from env with "False" fallback, ALLOWED_HOSTS = ["*"] is no longer literal, narrow localhost,127.0.0.1 env default, .env.example exists with the three documented vars and a real (not template-placeholder) secret key, .env remains in .gitignore while .env.example does not. Closes #637.

Added

  • TutorialMixin + TutorialStep + {% tutorial_bubble %} — declarative guided tours (ADR-002 Phase 1c) — Capstone of ADR-002 Phase 1: a one-import, zero-JavaScript way for any djust app to ship a real guided tour, onboarding flow, or wizard. Apps declare the tour as a list of TutorialStep dataclasses on a LiveView that mixes in TutorialMixin; the framework runs the state machine as a @background task, pushing a highlight + narrate + focus chain at each step's target via push_commands (Phase 1a), then either asyncio.sleep'ing for auto-advance steps or awaiting wait_for_event (Phase 1b) until the user actually fires the matching @event_handler. Four event handlers come for free — start_tutorial, skip_tutorial, cancel_tutorial, restart_tutorial — along with three instance attributes (tutorial_running, tutorial_current_step, tutorial_total_steps) for progress display in the view state. TutorialStep supports per-step target (CSS selector, required), message (narration text), position (top/bottom/left/right bubble hint), wait_for (handler name to suspend on), timeout (seconds — pairs with wait_for for bounded waits or used alone for auto-advance), on_enter/on_exit (optional extra JSChain pushes for per-step setup/teardown beyond the default highlight + narrate + focus), and highlight_class/narrate_event (override per-step CSS class and CustomEvent name when you need different visual treatment). Skip and cancel signals are raced against the wait via asyncio.wait(..., return_when=FIRST_COMPLETED) so either unblocks the current step immediately; WebSocket disconnect cancels the background task automatically so there's no lingering work, no leaked waiters, no stuck highlights. A new {% tutorial_bubble %} template tag renders a floating narration bubble that listens for tour:narrate CustomEvents at document level (dispatched at the step's target with bubbles: true), positions itself next to the target per the step's position hint, displays step N / total progress, and includes "Skip" and "Close" buttons pre-bound to the mixin's event handlers — the default bubble is marked dj-update="ignore" so morphdom doesn't clobber it during VDOM patches. The new client-side src/28-tutorial-bubble.js module (~140 lines, brings client.js to 30 modules) registers its listeners unconditionally at IIFE time, reads detail.text/target/position/step/total from the event, and updates the bubble's text + progress + position + visibility. The framework ships no CSS — apps style the bubble and highlight class themselves (the guide includes a minimal starter block). 26 new Python tests for the mixin covering TutorialStep dataclass (minimal, custom position, invalid position, empty target, empty message, wait_for+timeout, on_enter/on_exit), lifecycle (initial state, empty-steps no-op, single step, setup+cleanup chain order, multi-step order, idempotent start-while-running), wait_for_event integration (step suspends on user action, timeout advances silently, indefinite wait), skip/cancel paths (advance past current, abort loop, no-op when not running), on_enter/on_exit pushes, per-step highlight class override, and per-step narrate event override. 9 new Python tests for the tutorial_bubble template tag covering defaults, custom css_class/event/position, invalid-position fallback to "bottom", skip+cancel button bindings, text/progress element classes, and XSS escaping of hostile css_class and event kwargs. 12 new JS tests in tests/js/tutorial-bubble.test.js covering listener registration, text content updates, progress text updates, show/hide via data-visible, default/custom position application, missing-target graceful handling, missing-bubble graceful handling, tour:hide event, and repeated updates on subsequent events. Zero new runtime dependencies — stdlib asyncio + dataclasses + Django's format_html. Full documentation in the new docs/website/guides/tutorials.md guide with the simplest-possible example, state-machine description, TutorialStep reference, wait_for/timeout combinations table, on_enter/on_exit patterns, the bubble template tag docs, a starter CSS block, four usage patterns (auto-advance walkthrough, interactive onboarding, mixed, branching with custom handlers), skip/cancel UX, disconnect cleanup, debugging tips, and honest limitations (LiveComponent events don't propagate to parent waiters yet, actor-mode views bypass the dispatch hook, handler validation failures prevent the waiter from resolving except via timeout, single-user only — multi-user broadcast is Phase 4 in v0.5.x).

  • await self.wait_for_event(name, timeout=None, predicate=None) async primitive (ADR-002 Phase 1b) — Second half of the backend-driven UI Phase 1 primitives. Adds a new WaiterMixin (automatically included in LiveView) that lets a @background handler suspend until a specific @event_handler is called by the user, optionally filtered by a predicate, optionally bounded by a timeout. The returned dict is the kwargs that were passed to the matching handler. This is the primitive that makes "highlight this button, wait for the user to actually click it, then advance to the next step" work declaratively — required by TutorialMixin (Phase 1c) and by any server-driven flow that needs to pause mid-plan until real user input arrives. Implementation: ~180 lines in python/djust/mixins/waiters.py, a ~15-line hook in python/djust/websocket.py that calls _notify_waiters after every successful handler invocation, a ~10-line cleanup hook in the WebSocket disconnect path that cancels all pending waiters when the view tears down (so @background tasks unblock with CancelledError instead of leaking), and proper integration into LiveView's MRO via python/djust/mixins/__init__.py. The notify pass runs AFTER the handler completes so waiters created during a handler call aren't self-notified (prevents re-entrancy surprises where wait_for_event("X") inside an X handler would resolve against itself). Multiple concurrent waiters for the same event name all resolve with the same kwargs dict when that event fires — fan-out patterns work without manual coordination. Waiters for different event names are fully independent. A predicate that raises is treated as "no match" and logged via the djust.waiters logger, so a buggy predicate can't crash the event pipeline or deadlock a background task. 18 new Python tests covering: basic resolution, kwargs copy semantics, no-op on unmatched names, predicate filtering, predicate-that-raises treated as False with warning log, predicate=None matches any kwargs, timeout raises asyncio.TimeoutError, expired waiters removed from registry, indefinite waits without timeout, concurrent waiters for same event all resolve, waiters for different events are independent, partial resolution (some predicates match, others don't), _cancel_all_waiters unblocks pending futures with CancelledError and clears the registry, task cancellation removes the waiter, and stability under mid-iteration waiter-list mutation. Full documentation in the existing docs/website/guides/server-driven-ui.md guide with signature, predicate examples, concurrency semantics, timeouts and cleanup, composition with push_commands, and honest limitations (no component-event support yet, actor mode bypasses the hook, validation failures prevent handler execution which means waiters never resolve except via timeout).

  • LiveView.push_commands(chain) + djust:exec client-side auto-executor (ADR-002 Phase 1a) — First half of the backend-driven UI primitives proposed in ADR-002. Adds a one-line server-side helper self.push_commands(chain) that takes a djust.js.JSChain (shipped in v0.4.1 as the JS Commands fluent API) and pushes it to the current session as a djust:exec push event carrying the chain's JSON-serialized ops list. The client half is a new framework-provided src/27-exec-listener.js module that listens for djust:push_event CustomEvents on window, filters for event === 'djust:exec', and runs the ops via window.djust.js._executeOps(ops, document.body) — the same function that runs inline dj-click="[[...]]" JSON chains and fluent-API .exec() calls from hook code. No hook registration, no template markup, no user setup required: the auto-executor ships bound with client.js and is active on every djust page automatically. The server-side helper is type-safe — it rejects anything that isn't a JSChain with a clear TypeError pointing at the JS.* factory methods, preventing raw ops-list smuggling through the push_event path. push_commands and push_event share the same queue and preserve ordering, so handlers can interleave "push a flash message, add a CSS class, fire analytics, run an animation" in one deterministic sequence. 23 new Python tests covering single-op chains, multi-op ordering, empty chains, JSON round-trip, immutability of chains after push, type validation against strings/dicts/lists/None, queue composition with push_event, and per-op factory parity across all 11 JS Commands. 13 new JS tests in tests/js/exec-listener.test.js covering listener registration, single-op execution, multi-op ordering, multiple-class add_class, focus, dispatch with detail, filtering for non-djust:exec events, malformed-payload rejection (missing ops, non-array ops, missing detail), error resilience (one bad op doesn't break the chain), multiple independent exec fires, and end-to-end integration with the fluent window.djust.js chain factory. Zero new runtime dependencies. Full documentation in docs/website/guides/server-driven-ui.md with patterns, debugging tips, and pointers to Phase 1b (wait_for_event) and Phase 1c (TutorialMixin) still to come in v0.4.2.

[0.4.1] - 2026-04-11

Added

  • JS Commands — client-side DOM commands chainable from templates, views, hooks, and JavaScript — Closes the single biggest DX gap vs Phoenix LiveView 1.0. Eleven commands (show, hide, toggle, add_class, remove_class, transition, dispatch, focus, set_attr, remove_attr, push) that run locally without a server round-trip, plus a push escape hatch that mixes in server events when needed. Four equivalent entry points: (1) Python helper djust.js.JS — fluent chain builder that stringifies to a JSON command list, wrapped in SafeString for safe template embedding (<button dj-click="{{ JS.show('#modal').add_class('active', to='#overlay') }}">Open</button>). (2) Client-side window.djust.js — mirror of the Python API with camelCase method names for direct JavaScript use (window.djust.js.show('#modal').addClass('active', {to: '#overlay'}).exec()). (3) Hook API — every dj-hook instance now has a this.js() method returning a chain bound to the hook element (Phoenix 1.0 parity for programmable JS Commands from hook lifecycle callbacks). (4) Attribute dispatcherdj-click (and other event-binding attributes) detect whether the attribute value is a JSON command list ([[...]]) and execute it locally; plain handler names still fire server events as before (zero breaking changes). All commands support scoped targets: to=<selector> (absolute document.querySelectorAll), inner=<selector> (scoped to origin element's descendants), closest=<selector> (walk up the DOM from origin) — a single <button dj-click="{{ JS.hide(closest='.modal') }}">Close</button> works in every modal with no per-instance IDs. The push command accepts page_loading=True to show the navigation-level loading bar while the event round-trips. Chains are immutable — every chain method returns a new JSChain, so reusing a base chain across multiple call sites never cross-contaminates. 37 new Python tests (every command + target validation + chain immutability + HTML/SafeString integration + template rendering) and 30 new JS tests (every command executing against real DOM + target resolution + chain fluency + attribute dispatcher + backwards-compat for plain event names + parseCommandValue edge cases). Zero new dependencies — the Python helper is stdlib-only and the JS interpreter is ~350 lines in a new src/26-js-commands.js module. Full guide in docs/website/guides/js-commands.md with examples for templates, hooks, chaining, and the "when to reach for what" decision tree.

  • dj-paste — paste event handling — New attribute that fires a server event when the user pastes content into a bound element (<textarea dj-paste="handle_paste">). The client extracts structured payload from the ClipboardEvent in one pass: text (clipboardData.getData('text/plain')), html (getData('text/html') for rich paste from Word/Google Docs/web pages), has_files (bool), and files (list of {name, type, size} metadata dicts for every file in clipboardData.files). When the element also carries a dj-upload="<slot>" attribute, the clipboard's FileList is routed through the existing upload pipeline — image-paste → chat, CSV-paste → table, etc. — via a new window.djust.uploads.queueClipboardFiles(element, fileList) export. Participates in the standard interaction pipeline (dj-confirm, dj-lock). By default the browser's native paste still happens so hybrid editors feel natural; add dj-paste-suppress to intercept fully (useful when routing image paste to an upload slot without dumping a data URL into a <div contenteditable>). Positional args in the attribute syntax (dj-paste="handle_paste('chat', 42)") forward via kwargs["_args"]. 11 new JS tests covering text extraction, HTML extraction, file metadata, suppress flag, missing clipboardData, double-bind protection, positional args, upload routing with and without a dj-upload slot, and graceful degradation when getData('text/html') throws. ~80 lines JS. Full guide in docs/website/guides/dj-paste.md.

  • djust_audit --ast — AST security anti-pattern scanner (#660) — Adds a new mode to djust_audit that walks the project's Python source and Django templates looking for five specific security anti-patterns, each motivated by a live vulnerability or near-miss in the 2026-04-10 a downstream consumer penetration test. Seven stable finding codes djust.X001djust.X007: X001 (ERROR) — possible IDOR: Model.objects.get(pk=...) inside a DetailView / LiveView without a sibling .filter(owner=request.user) (or user=, tenant=, organization=, team=, created_by=, author=, workspace=) scoping the queryset. X002 (WARN) — state-mutating @event_handler without any permission check (no class-level login_required/permission_required, no @permission_required/@login_required). X003 (ERROR) — SQL string formatting: .raw() / .extra() / cursor.execute() passed an f-string, a .format() call, or a "..." % ... binary-op. X004 (ERROR) — open redirect: HttpResponseRedirect(request.GET[...]) / redirect(...) without an url_has_allowed_host_and_scheme or is_safe_url guard in the enclosing function. X005 (ERROR) — unsafe mark_safe / SafeString wrapping an interpolated string (XSS risk). X006 (WARN) — template uses {{ var|safe }} (regex scan of .html files). X007 (WARN) — template uses {% autoescape off %}. Suppression via # djust: noqa X001 on the offending line, or {# djust: noqa X006 #} inside templates. New CLI flags: --ast, --ast-path <dir>, --ast-exclude <prefix> [...], --ast-no-templates. Supports --json and --strict (fail on warnings too). 52 new tests covering positive + negative cases for every checker, management-command integration, template scanning, and noqa suppression. Zero new runtime dependencies — stdlib ast + re. Full documentation in docs/guides/djust-audit.md and docs/guides/error-codes.md#ast-anti-pattern-scanner-findings-x0xx. Closes the v0.4.1 audit-enhancement batch (#657/#659/#660/#661 all shipped).

  • New consolidated djust_audit command guidedocs/guides/djust-audit.md documents all five modes of the command (default introspection, --permissions, --dump-permissions, --live, --ast), every CLI flag, CI integration examples, and exit-code conventions. Cross-linked from docs/guides/security.md.

  • Error code reference expanded with 44 new codesdocs/guides/error-codes.md now covers the A0xx static audit checks (7 codes: A001, A010, A011, A012, A014, A020, A030), the P0xx permissions-document findings (7 codes: P001–P007), and the L0xx runtime-probe findings (30 codes: L001–L091). Every code gets severity, cause, fix, and a reference to the related issue/PR.

  • {% live_input %} template tag — standalone state-bound form fields for non-Form views (#650)FormMixin.as_live_field() and WizardMixin.as_live_field() render form fields with proper CSS classes, dj-input/dj-change bindings, and framework-aware styling — but only for views backed by a Django Form class. This leaves non-form views (modals, inline panels, search boxes, settings pages, anywhere state lives directly on view attributes) without an equivalent helper. The new {% live_input %} tag fills this gap with a lightweight alternative that needs no Form class or WizardMixin. Supports 12 field types (text, textarea, select, password, email, number, url, tel, search, hidden, checkbox, radio), explicit event= override (defaults sensibly per type — textdj-input, select/radio/checkboxdj-change, hidden → none), debounce=/throttle= passthrough, framework CSS class resolution via config.get_framework_class('field_class'), HTML attribute passthrough with underscore-to-dash normalisation (aria_label="Search"aria-label="Search"), and a tested XSS escape boundary via a new shared djust._html.build_tag() helper. Example: {% live_input "text" handler="search" value=query debounce="300" placeholder="Search..." %}. 56 new tests including an explicit XSS matrix across every field type and attribute. See docs/guides/live-input.md for the full setup guide.

  • djust_audit --live <url> — runtime security-header and CSWSH probe (#661) — Adds a new mode to djust_audit that fetches a running deployment with stdlib urllib and validates security headers (HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, COOP, CORP), cookies (HttpOnly, Secure, SameSite on session/CSRF cookies), information-disclosure paths (/.git/config, /.env, /__debug__/, /robots.txt, /.well-known/security.txt), and optionally probes the WebSocket endpoint with Origin: https://evil.example to verify the CSWSH defense from #653 is actually enforced end-to-end. This catches the class of production issues where the setting is correctly configured in settings.py but the response is stripped, rewritten, or never emitted by the time it reaches the client — a downstream consumer pentest caught a critical Content-Security-Policy missing case this way (django-csp was configured but the header was absent from production responses, stripped by an nginx ingress). 30 new stable finding codes djust.L001djust.L091 cover every check class so CI configs can suppress specific codes by number. New CLI flags: --live <url>, --paths (multi-URL), --no-websocket-probe, --header 'Name: Value' (for staging auth), --skip-path-probes (for WAF-protected environments). Supports --json and --strict (fail on warnings too). Zero new runtime dependencies — stdlib urllib for HTTP, optional websockets package for the WebSocket probe (skipped with an INFO finding if not installed).

    See docs/guides/djust-audit.md.

  • New static security checks in djust_check / djust_audit (#659) — Seven new check IDs fire from check_configuration when Django runs python manage.py check: A001 (ERROR) — WebSocket router not wrapped in AllowedHostsOriginValidator (static-analysis companion to #653 for existing apps built from older scaffolds). A010 (ERROR) — ALLOWED_HOSTS = ["*"] in production. A011 (ERROR) — ALLOWED_HOSTS mixes "*" with explicit hosts (the wildcard makes the explicit entries meaningless). A012 (ERROR) — USE_X_FORWARDED_HOST=True combined with wildcard ALLOWED_HOSTS enables Host header injection. A014 (ERROR) — SECRET_KEY starts with django-insecure- in production (scaffold default not overridden before deployment). A020 (WARNING) — LOGIN_REDIRECT_URL is a single hardcoded path but the project has multiple auth groups/permissions (catches the "every role lands on the same dashboard" anti-pattern). A030 (WARNING) — django.contrib.admin installed without a known brute-force protection package (django-axes, django-defender, etc.). Each check has essentially zero false-positive risk, has a fix_hint pointing at the remediation, and was motivated by the 2026-04-10 a downstream consumer pentest report. Out of scope for this PR: manifest scanning (k8s/helm/docker-compose env blocks) — deferred to a follow-up. Python-level settings.py values cover the common case.

  • djust_audit --permissions permissions.yaml — declarative permissions document for CI-level RBAC drift detection (#657) — Adds a new flag to djust_audit that validates every LiveView against a committed, human-readable YAML document describing the expected auth configuration for each view. CI fails on any deviation (view declared public but has auth in code, permission list mismatch, undeclared view in strict mode, stale declaration, etc.). This closes a structural gap the existing audit couldn't catch: djust_audit today can tell "no auth" from "some auth", but not that login_required=True should have been permission_required=['claims.view_supervisor']. The permissions document IS the ground truth. Seven stable error codes (djust.P001 through djust.P007) cover every deviation class. Also adds --dump-permissions to bootstrap a starter YAML from existing code, and --strict to fail CI on any finding. Full documentation in docs/guides/permissions-document.md. Motivated by a downstream consumer pentest finding 10/11 where every view had login_required=True set and djust_audit reported them all as protected, but the lowest-privilege authenticated user could ID-walk the entire database.

  • WizardMixin for multi-step LiveView form wizards — General-purpose mixin managing step navigation, per-step validation, and data collection for guided form flows. Provides next_step, prev_step, go_to_step, update_step_field, validate_field, and submit_wizard event handlers. Template context includes step indicators, progress, form data/errors, and pre-rendered field HTML via as_live_field(). Re-validates all steps on submission to guard against tampered WebSocket replays. (#632)

    See docs/website/guides/wizards.md.

Security

  • LOW: Nonce-based CSP support — drop 'unsafe-inline' from script-src / style-src — djust's inline <script> and <style> emissions (handler metadata bootstrap in TemplateMixin._inject_handler_metadata, live_session route map in routing.get_route_map_script, and the PWA template tags djust_sw_register, djust_offline_indicator, djust_offline_styles) now read request.csp_nonce when available (set by django-csp when CSP_INCLUDE_NONCE_IN covers the relevant directive) and emit a nonce="..." attribute on the tag. When no nonce is available (django-csp not installed, or CSP_INCLUDE_NONCE_IN not set), the tags emit without a nonce attribute — fully backward compatible with apps still allowing 'unsafe-inline'. Apps that want strict CSP can now set CSP_INCLUDE_NONCE_IN = ("script-src", "script-src-elem", "style-src", "style-src-elem") in settings.py, drop 'unsafe-inline' from CSP_SCRIPT_SRC / CSP_STYLE_SRC, and get strict CSP XSS protection across all djust-generated inline content. The PWA tags djust_sw_register, djust_offline_indicator, and djust_offline_styles now use takes_context=True to read the request from the template context — they still work with the same template syntax ({% djust_sw_register %} etc.) as long as a RequestContext is used (Django's default for template rendering). See docs/guides/security.md for the full setup. Reported via external penetration test 2026-04-10 (FINDING-W06). Closes the v0.4.1 security hardening batch (#653 / #654 / #655). (#655)

  • MEDIUM: Gate VDOM patch timing/performance metadata behind DEBUG / DJUST_EXPOSE_TIMINGLiveViewConsumer previously attached timing (handler/render/total ms) and performance (full nested timing tree with handler and phase names) to every VDOM patch response unconditionally, regardless of settings.DEBUG. Combined with CSWSH (#653) this let cross-origin attackers observe server-side code-path timings, enabling timing-based code-path differentiation (DB hit vs cache miss, valid vs invalid CSRF), internal handler/phase name disclosure, and load-based DoS scheduling. Now gated on a new helper _should_expose_timing() which returns True only when settings.DEBUG or the new settings.DJUST_EXPOSE_TIMING is True. Upgrade notes: production behavior change — existing clients that consumed response.timing / response.performance in production will no longer see those fields; opt in via DJUST_EXPOSE_TIMING = True in settings for staging/profiling. The browser debug panel is unaffected (it receives timing via the existing _attach_debug_payload path, which is already gated on DEBUG). Reported via external penetration test 2026-04-10. References: CWE-203, CWE-215, OWASP A09:2021. (#654)

  • HIGH: Validate WebSocket Origin header to prevent Cross-Site WebSocket Hijacking (CSWSH)LiveViewConsumer.connect() previously accepted the WebSocket handshake without validating the Origin header, and DjustMiddlewareStack did not wrap the router in an origin validator. A cross-origin attacker could mount any LiveView and dispatch any event from a victim's browser. Now the consumer rejects disallowed origins with close code 4403 before accepting the handshake, and DjustMiddlewareStack wraps its inner application in channels.security.websocket.AllowedHostsOriginValidator by default (defense in depth). Missing Origin is still allowed so non-browser clients (curl, test WebsocketCommunicator) continue to work. Upgrade notes: ensure settings.ALLOWED_HOSTS does NOT contain * in production; if you need to opt out for a specific stack, use DjustMiddlewareStack(inner, validate_origin=False) (not recommended). Reported via external penetration test 2026-04-10. (#653)

  • Enforce login_required on HTTP GET path — Views with login_required = True rendered full HTML to unauthenticated users on the initial HTTP GET. The WebSocket connection was correctly rejected, but the pre-rendered page content was already visible. Now calls check_view_auth() before mount() on HTTP GET and returns 302 to LOGIN_URL. Also calls handle_params() after mount() on HTTP GET to match the WebSocket path's behavior, preventing state flash on URL-param-dependent views. (#636, fixes #633, #634)

Fixed

  • Prevent SynchronousOnlyOperation in PerformanceTracker.track_context_size — The tracker called sys.getsizeof(str(context)), which triggered QuerySet.__repr__() on any unevaluated querysets in the context dict. __repr__ calls list(self[:21]), evaluating the queryset against the database — raising SynchronousOnlyOperation in the async WebSocket path. Now uses a shallow per-value getsizeof sum that does not invoke __repr__/__str__ on values, so lazy objects stay lazy. Size estimates are now slightly less precise (don't include recursive inner size) but safe in async contexts. (#651, fixes #649)

  • Apply RemoveChild patches before batched InsertChild in same parent groupapplyPatches in client.js:1379-1440 was filtering InsertChild patches out of each parent group and applying them via DocumentFragment before iterating the group for the RemoveChild patches in that same parent, violating the top-level Remove → Insert phase order. This was latent for keyed content (monotonic dj-ids meant removes still found targets by ID), but fired for <!--dj-if--> placeholder comments — they have no dj-id (only elements get IDs), so their removes fall back to index-based lookup, and by the time the removes ran, the batched inserts had already prepended the new content and shifted indices. The removes then deleted the just-inserted content, leaving empty tab content on multi-tab views (symptom: a downstream consumer tab switches showing blank content after the first switch). Fix: split each parent group into non-Insert vs Insert lists, apply all non-Insert patches first in their phase-sorted order, then batch the inserts. (#643, fixes #641, closes #642)

  • dj-patch on <a> tags uses href when attribute value is empty — Boolean dj-patch on anchor elements (<a href="?tab=docs" dj-patch>) was resolving to the current URL instead of the href destination. Now falls back to el.getAttribute('href') when dj-patch is empty and the element is <a>. (#640)

  • Normalize Model instances in render_full_template before passing to Rust — Django FK fields are class-level descriptors not present in __dict__. Rust's FromPyObject extracts __dict__ which has claimant_id=1 (raw FK int) instead of the related object. Now always calls normalize_django_value() on pre-serialized context so FK relationships are resolved via getattr() and traversable with dot notation ({{ claim.claimant.first_name }}). (#639)

  • Render Django Form/BoundField to SafeString HTML in template context{{ form.field_name }} rendered as empty string because the Rust renderer extracted Form.__dict__ which doesn't contain computed BoundField attributes. Now pre-renders Form and BoundField objects to SafeString HTML via widget.render() in all four code paths (serialization, template serialization, template rendering, and LiveView state sync). (#631, fixes #621)

  • Correct has_ids attribute name in WebSocket mount responsewebsocket.py checked for "data-dj-id=" but the Rust renderer emits "dj-id=" attributes. This caused _stampDjIds() to be skipped on pre-rendered pages, breaking VDOM patches for large content swaps (e.g. tab switching) while small patches still worked. The SSE path already had the correct check. (#630, fixes #629)

  • Sync input .value from attribute after innerHTML/VDOM patch — When navigating backward in a multi-step wizard, text input values were not visually restored even though the server sent correct VDOM patches. setAttribute('value', x) only updates the HTML attribute (defaultValue), not the .value DOM property. Now syncs .value from the attribute in preserveFormValues(), broadcast patches, and morphElement(). Skips focused inputs, checkboxes, radios, and file inputs. (#625, fixes #624)

[0.4.0] - 2026-03-27

Security

  • Fix 25 CodeQL code-scanning alerts in client.js and debug-panel.js — Added UNSAFE_KEYS guard to VDOM SetAttr/RemoveAttr patches (rejects __proto__, constructor, prototype keys), replaced direct property assignment with Object.defineProperty() in debug panel state cloning, converted template literal logs to format strings to prevent log injection, and added XSS suppression comments for trusted server-rendered HTML. (#597)

Removed

  • whitenoise dependency — djust's ASGIStaticFilesHandler in djust.asgi.get_application() already handles static file serving at the ASGI layer, making WhiteNoise middleware redundant. Removed whitenoise from dependencies, scaffolded projects, and the demo project. Removed system check C006 (daphne without WhiteNoise). (#584)

Added

  • {% dj_flash %} template tag in Rust renderer — Registered DjFlashTagHandler so the flash container renders correctly when templates are processed by the Rust engine. Previously, the tag was only registered as a Django template tag and silently dropped by the Rust renderer. (#590)

    See docs/website/guides/flash-messages.md.

  • Navigation lifecycle events and CSS classdjust:navigate-start / djust:navigate-end CustomEvents and .djust-navigating CSS class on [dj-root] during dj-navigate transitions. Enables CSS-only page transitions without monkey-patching pageLoading. (#585)

    See docs/website/core-concepts/events.md.

  • manage.py djust_doctor diagnostic command -- checks Rust extension, Python/Django versions, Channels, Redis, templates, static files, routing, and ASGI server in one command. Supports --json, --quiet, --check NAME, and --verbose flags.

  • Enhanced VDOM patch error messages -- patch failures now include patch type, dj-id, parent element info, and suggested causes (third-party DOM modification, {% if %} block changes). In DEBUG_MODE, a console group with full patch detail is shown. Batch failure summaries include which patch indices failed.

    See docs/website/guides/flash-messages.md.

  • DEBUG-mode enriched WebSocket errors -- send_error includes debug_detail (unsanitized message), traceback (last 3 frames), and hint (actionable suggestion) when settings.DEBUG=True. handle_mount lists available LiveView classes when class lookup fails.

    See docs/website/guides/error-overlay.md.

  • Debug panel warning interceptor -- intercepts console.warn calls matching [LiveView] prefix and surfaces them as a warning badge on the debug button. Configurable auto-open via LIVEVIEW_CONFIG.debug_auto_open_on_error.

    See docs/website/advanced/debug-panel.md.

  • Latency simulator in debug panel -- test loading states and optimistic updates with simulated network delay. Presets (Off/50/100/200/500ms), custom value, jitter control, localStorage persistence, and visual badge on the debug button. Latency is injected on both WebSocket send and receive for full round-trip simulation. Only active when DEBUG_MODE=true.

    See docs/website/advanced/debug-panel.md.

  • Form recovery on reconnect — After WebSocket reconnects, form fields with dj-change or dj-input automatically fire change events to restore server state. Compares DOM values against server-rendered defaults and only fires for fields that differ. Use dj-no-recover to opt out individual fields. Fields inside dj-auto-recover containers are skipped (custom handler takes precedence). Works over both WebSocket and SSE transports.

  • Reconnection backoff with jitter — Exponential backoff with random jitter (AWS full-jitter strategy) prevents thundering herd on server restart. Min delay 500ms, max delay 30s, increased from 5 to 10 max attempts. Attempt count shown in reconnection banner (dj-reconnecting-banner CSS class) and exposed via data-dj-reconnect-attempt attribute and --dj-reconnect-attempt CSS custom property on <body>. Banner and attributes cleared on successful reconnect or intentional disconnect.

    See docs/website/guides/reconnection.md.

  • page_title / page_meta dynamic document metadata — Update document.title and <meta> tags from any LiveView handler via property setters (self.page_title = "...", self.page_meta = {"description": "..."}). Uses side-channel WebSocket messages (no VDOM diff needed). Supports og: and twitter: meta tags with correct property attribute. Works over both WebSocket and SSE transports.

  • dj-copy enhancements — Selector-based copy (dj-copy="#code-block" copies the element's textContent), configurable feedback text (dj-copy-feedback="Done!"), CSS class feedback (dj-copy-class adds a custom class for 2s, default dj-copied), and optional server event (dj-copy-event="copied" fires after successful copy for analytics). Backward compatible with existing literal copy behavior.

  • dj-auto-recover attribute for reconnection recovery — After WebSocket reconnects, elements with dj-auto-recover="handler_name" automatically fire a server event with serialized DOM state (form field values and data-* attributes from the container). Enables the server to restore custom state lost during disconnection. Does not fire on initial page load. Supports multiple independent recovery elements per page.

  • dj-debounce / dj-throttle HTML attributes — Apply debounce or throttle to any dj-* event attribute (dj-click, dj-change, dj-input, dj-keydown, dj-keyup) directly in HTML: <button dj-click="search" dj-debounce="300">. Takes precedence over data-debounce/data-throttle. Supports dj-debounce="blur" to defer until element loses focus (Phoenix parity). dj-debounce="0" disables default debounce on dj-input. Each element gets its own independent timer.

  • Connection state CSS classesdj-connected and dj-disconnected classes are automatically applied to <body> based on WebSocket/SSE transport state. Enables CSS-driven UI feedback for connection status (e.g., dimming content, showing offline banners). Both classes are removed on intentional disconnect (TurboNav). Phoenix LiveView's phx-connected/phx-disconnected equivalent.

  • dj-cloak attribute for FOUC prevention — Elements with dj-cloak are hidden (display: none !important) until the WebSocket/SSE mount response is received, preventing flash of unconnected content. CSS is injected automatically by client.js — no user stylesheet changes needed. Phoenix LiveView's phx-no-feedback equivalent.

  • Page loading bar for navigation transitions — NProgress-style thin loading bar at the top of the page during TurboNav and live_redirect navigation. Always active by default. Exposed as window.djust.pageLoading with start(), finish(), and enabled for manual control. Disable via window.djust.pageLoading.enabled = false or CSS override.

    See docs/website/guides/navigation.md.

  • dj-scroll-into-view attribute for auto-scroll on render — Elements with dj-scroll-into-view are automatically scrolled into view after DOM updates (mount, VDOM patch). Supports scroll behavior options: "" (smooth/nearest, default), "instant", "center", "start", "end". One-shot per DOM node — uses WeakSet tracking so the same element isn't re-scrolled on every patch, but VDOM-replaced fresh nodes scroll correctly.

    See docs/website/core-concepts/events.md.

  • dj-window-* / dj-document-* event scoping — Bind event listeners on window or document while using the declaring element for context extraction (component_id, dj-value-* params). Supports dj-window-keydown, dj-window-keyup, dj-window-scroll, dj-window-click, dj-window-resize, dj-document-keydown, dj-document-keyup, dj-document-click. Key modifier filtering (e.g., dj-window-keydown.escape="close_modal") works the same as dj-keydown. Scroll and resize events default to 150ms throttle. Phoenix LiveView's phx-window-* equivalent, plus dj-document-* as a djust extension.

    See docs/website/core-concepts/events.md.

  • dj-click-away attribute — Fire a server event when the user clicks outside an element: <div dj-click-away="close_dropdown">. Uses capture-phase document listener so stopPropagation() inside the element doesn't prevent detection. Supports dj-confirm for confirmation dialogs and dj-value-* params from the declaring element.

  • dj-shortcut attribute for declarative keyboard shortcuts — Bind keyboard shortcuts on any element with modifier key support: <div dj-shortcut="ctrl+k:open_search:prevent, escape:close_modal">. Supports ctrl, alt, shift, meta modifiers, comma-separated multiple bindings, and prevent modifier to suppress browser defaults. Shortcuts are automatically skipped when the user is typing in form inputs (override with dj-shortcut-in-input attribute). Event params include key, code, and shortcut (the matched binding string).

  • _target param in form change/input events — When multiple form fields share one dj-change or dj-input handler, the _target param now includes the triggering element's name (or id, or null), letting the server know which field changed. For dj-submit, includes the submitter button's name if available. Matches Phoenix LiveView's _target convention.

    See docs/website/core-concepts/events.md.

  • dj-disable-with attribute for submit buttons — Automatically disable submit buttons during form submission and replace their text with a loading message: <button type="submit" dj-disable-with="Saving...">Save</button>. Prevents double-submit and gives instant visual feedback. Works with both dj-submit forms and dj-click buttons. Original text is restored after server response.

  • dj-lock attribute for concurrent event prevention — Disable an element until its event handler response arrives from the server: <button dj-click="save" dj-lock>Save</button>. Prevents rapid double-clicks from triggering duplicate server events. For non-form elements (e.g., <div>), applies a djust-locked CSS class instead of the disabled property. All locked elements are unlocked on server response.

    See docs/website/core-concepts/events.md.

  • dj-mounted event for element lifecycle — Fire a server event when an element with dj-mounted="handler_name" enters the DOM after a VDOM patch: <div dj-mounted="on_chart_ready" dj-value-chart-type="bar">. Does not fire on initial page load (only after subsequent patches). Includes dj-value-* params from the mounted element. Uses a WeakSet to prevent duplicate fires for the same DOM node.

    See docs/website/core-concepts/events.md.

  • Priority-aware event queue for broadcast and async updates — Server-initiated broadcasts (server_push) and async completions (_run_async_work) are now tagged with source="broadcast" and source="async" respectively, and the client buffers them during pending user event round-trips (same as tick buffering from #560). server_push now acquires the render lock and yields to in-progress user events to prevent version interleaving. Client-side pending event tracking upgraded from single ref to Set-based tracking, supporting multiple concurrent pending events. Buffer flushes only when all pending events resolve.

  • manage.py djust_gen_live — Model-to-LiveView scaffolding generator — Generate a complete CRUD LiveView scaffold from a model name and field definitions: python manage.py djust_gen_live blog Post title:string body:text. Creates views.py (with @event_handler CRUD operations), urls.py (using live_session() routing), HTML template (with dj-* directives), and tests.py. Supports --dry-run, --force, --no-tests, --api (JSON mode) options. Handles all Django field types including FK relationships. Search uses Q objects for OR logic across text fields.

    See docs/guides/scaffolding.md.

  • on_mount hooks for cross-cutting mount logic — Module-level hooks that run on every LiveView mount, declared via @on_mount decorator and on_mount class attribute. Use cases: authentication checks, telemetry, tenant resolution, feature flags. Hooks run after auth checks, before mount(). Return a redirect URL string to halt the mount pipeline. Hooks are inherited via MRO (parent-first, deduplicated). Includes V009 system check for validation. Phoenix on_mount v0.17+ parity.

    See docs/website/guides/on-mount-hooks.md.

  • put_flash(level, message) and clear_flash() for ephemeral flash notifications — Phoenix put_flash parity. Queue transient messages (info, success, warning, error) from any event handler; they are flushed to the client over WebSocket/SSE after each response. Includes {% dj_flash %} template tag with auto-dismiss and ARIA role="status" / role="alert" support. (#568)

    See docs/website/guides/flash-messages.md.

  • handle_params called on initial mounthandle_params(params, uri) is now invoked after mount() on the initial WebSocket connect, not just on subsequent URL changes. This matches Phoenix LiveView's handle_params/3 contract and eliminates the need to duplicate URL-parsing logic between mount() and handle_params(). Views that don't override handle_params are unaffected (default is a no-op).

    See docs/website/core-concepts/liveview.md.

  • dj-value-* — Static event parameters — Pass static values alongside events without data-* attributes or hidden inputs: <button dj-click="delete" dj-value-id:int="{{ item.id }}" dj-value-type="soft">. Supports type-hint suffixes (:int, :float, :bool, :json, :list), kebab-to-snake_case conversion, and prototype pollution prevention. Works with all event types: dj-click, dj-submit, dj-change, dj-input, dj-keydown, dj-keyup, dj-blur, dj-focus, dj-poll. Phoenix LiveView's phx-value-* equivalent.

    See docs/website/core-concepts/events.md.

Fixed

  • True/False/None literals resolved as empty string in custom tag argsget_value() didn't recognize Python boolean/None literals, so {% tag show_labels=False %} produced show_labels= (empty string) instead of show_labels=False. Now handles True/true, False/false, and None/none as literal values. (#602)

  • Flash and page_metadata not delivered over HTTP POST fallbackput_flash() and page_title/page_meta side-channel commands were only flushed over WebSocket. HTTP POST responses now drain _pending_flash and _pending_page_metadata and include them as _flash and _page_metadata arrays in the JSON response. (#590)

  • Custom tag args containing lists/objects serialized as [List]/[Object]Value::List and Value::Object in custom tag arguments were stringified via the Display trait, destroying structured data before it reached Python handlers. Now serialized as JSON via serde_json. (#589)

  • Django filters not applied in custom tag arguments{% tag key=var|length %} rendered the literal string instead of the computed value because arg resolution used context.get() (plain lookup) instead of get_value() (filter-aware). (#591)

  • {% if %} inside HTML tag after {{ variable }} emits <!--dj-if--> commentis_inside_html_tag() only checked the immediately preceding token, missing tag context when {{ variable }} tokens appeared between the tag opening and {% if %}. Added is_inside_html_tag_at() that scans all preceding tokens. (#580)

  • Tick/event version mismatch silently drops user input — Server-initiated ticks could collide with user events, causing VDOM version divergence that silently discarded patches. Added server-side asyncio.Lock to serialize tick and event render operations, priority yielding so ticks skip during user events, client-side tick patch buffering during pending event round-trips, and monotonic event ref tracking for request/response matching. (#560)

  • Focus lost during VDOM patches — When the server pushed VDOM patches (e.g., updating a counter while the user was typing), the focused input/textarea lost focus, cursor position, selection range, and scroll position. Added saveFocusState() / restoreFocusState() around the applyPatches() cycle to capture and restore activeElement, selectionStart/selectionEnd, and scrollTop/scrollLeft. Element matching uses id → name → dj-id → positional index. Broadcast (remote) updates correctly skip focus restoration.

  • VDOM patching fails when {% if %} blocks add/remove DOM elements — Comment node placeholders (<!--dj-if-->) emitted by the Rust template engine were excluded from client-side child index resolution (getSignificantChildren and getNodeByPath), causing path traversal errors and silent patch failures. Also added #comment handling to createNodeFromVNode so comment placeholders can be correctly created during InsertChild patches. (#559)

[0.3.8] - 2026-03-19

Fixed

  • Tick auto-refresh causes VDOM version mismatch, silently drops user events_run_tick always called render_with_diff() even when handle_tick() made no state changes, incrementing the VDOM version on every tick. When a user event interleaved with a tick, the client and server versions diverged, causing all subsequent patches to be silently discarded. Tick now uses _snapshot_assigns to skip render when no public assigns changed. (#560)
  • WS VDOM cache key collision across tabs — All WebSocket LiveViews shared a single RustLiveView cache slot keyed by /ws/live/, causing multi-tab sessions to overwrite each other's compiled templates. Cache key now uses request.path (the actual page URL) so each view gets its own VDOM baseline. (#561)
  • Canvas width/height cleared during html_update morphmorphElement removed attributes absent from server HTML, resetting canvas 2D contexts and blanking Chart.js charts. Canvas width and height are now preserved during attribute sync. (#561)
  • _force_full_html not checked in handle_url_change — Views that set _force_full_html = True in handle_params (e.g., when {% for %} loop lengths change) still received VDOM patches instead of full HTML. The flag is now checked after render_with_diff() in both handle_event and handle_url_change. (#559, #561)

Added

  • dj-patch on selects/inputs uses WS url_change — Select and input elements with dj-patch now update via pushState + WebSocket url_change instead of full page reload. A delegated document change listener survives DOM replacement by morphdom. dj-patch-reload attribute remains as an opt-in escape hatch for full page navigation. (#561)

[0.3.7] - 2026-03-16

Fixed

  • FormMixin: serialization, event handling, and ModelForm support — Fixed 6 issues blocking production use of FormMixin with ModelForm over WebSocket: added @event_handler to submit_form() and validate_field(); renamed form_instance to private _form_instance with backward-compatible property; store model_pk/model_label as public attributes for re-hydration after WS session restore; sync form_data from saved instance after form_valid(); use FK PK instead of related object; auto-populate form_choices with serializable tuples. (#545)
  • dj-hook elements not re-initialized after html_update or html_recovery — When VDOM patches failed and djust fell back to full HTML replacement, updateHooks() was never called, leaving hook elements stale (charts showing old data, canvases empty). Added updateHooks() to all DOM replacement paths: html_update, html_recovery, TurboNav reinit, embedded view update, lazy hydration, and streaming updates. (#548)
  • __version__ not updated by make versionmake version only updated pyproject.toml and Cargo.toml but not the hardcoded __version__ in __init__.py files. djust.__version__ now stays in sync with the package version. (#547)

Changed

  • Extract reinitAfterDOMUpdate() to DRY up post-DOM-update calls — The repeated pattern of initReactCounters() + initTodoItems() + bindLiveViewEvents() + updateHooks() across 10+ call sites is now a single function. New DOM replacement paths only need one call. (#549)
  • Extract addEventContext() to consolidate component/embedded view ID extraction — The 8-line getComponentId/getEmbeddedViewId pattern appeared 4 times in event binding; now a single helper. (#551)
  • Extract isWSConnected() to replace WebSocket state guard chains — The liveViewWS && liveViewWS.ws && liveViewWS.ws.readyState === WebSocket.OPEN pattern appeared across 4 files; now a single predicate. (#552)
  • Extract clearOptimisticPending() to consolidate CSS class cleanup — The querySelectorAll('.optimistic-pending') removal loop appeared 4 times across 2 files; now a single function. (#553)
  • Standardize DJUST_CONFIG access via get_djust_config() — Replaced 10+ inline getattr(settings, "DJUST_CONFIG", {}) try/except blocks across tenants, PWA, and storage modules with a single get_djust_config() helper in config.py. (#554)
  • Extract generic BackendRegistry class — The duplicated lazy-init / set / reset pattern in state_backends/registry.py and backends/registry.py now delegates to a shared BackendRegistry class in utils.py. (#555)
  • Extract is_model_list() helper — The repeated isinstance(value, list) and value and isinstance(value[0], models.Model) check is now a single is_model_list() function in utils.py, used in mixins/context.py and mixins/request.py. (#556)

[0.3.6] - 2026-03-14

Breaking Changes

  • model.id now returns the native type, not a string_serialize_model_safely() previously wrapped obj.pk with str() when producing the "id" key, causing template comparisons like {% if edit_id == todo.id %} to fail silently when edit_id was an integer. model.id now matches model.pk and returns the native Python type (e.g. int, UUID). Migration: if your templates or event handlers compare model.id against string literals or string-typed variables, update them to use the native type. PR #262 fixed .pk; this PR (#472) completes the fix for .id.

Fixed

  • Skip redundant mount() on WebSocket connect for pre-rendered pages — When the client sends has_prerendered=true on WS connect and saved state exists in the session (written during the HTTP GET), the view's attributes are restored from session instead of re-running mount(). This eliminates the double page-load cost for views with expensive mount() implementations (e.g. directory scans, API calls). Falls back to calling mount() normally when no saved state is found. _ensure_tenant() is now called unconditionally before the restore/mount decision, fixing a regression where multi-tenant views had self.tenant=None on WS connect for pre-rendered pages. (#542)
  • djust cache --all now correctly clears all sessions on the Redis backend — The CLI called cleanup_expired(ttl=0) to force-clear sessions, but the semantics of ttl=0 changed in 0.3.5 to mean "never expire". The command now calls the explicit delete_all() method, which uses a Redis pipeline for an efficient single round-trip bulk delete. (#409)
  • dj-params attribute no longer silently dropped — Between 0.3.2 and 0.3.6rc2, dj-params was removed from the client event-binding code. Templates using dj-params='{"key": value}' continued to fire click events but the server received params: {}. The attribute is now read and merged into the params object for backward compatibility. A console.warn is emitted in debug mode (globalThis.djustDebug) to notify developers to migrate. (#469)
  • Prefetch Set not cleared on SPA navigation — The client-side _prefetched Set persisted across live_redirect navigations, preventing links on the new view from being prefetched. Added clear() to window.djust._prefetch and call it in handleLiveRedirect() so each SPA navigation starts with a fresh prefetch state. (#402)
  • Auto-reload on unrecoverable VDOM state — When VDOM patch recovery fails because recovery HTML is unavailable (e.g. after server restart), the client now auto-reloads the page instead of showing a confusing error overlay. The server sends recoverable: false to signal the client. (#421)
  • {% djust_pwa_head %} and other custom tags with quoted arguments containing spaces now render correctly — The Rust template lexer used split_whitespace() to tokenize tag arguments, which broke quoted values like name="My App" into separate tokens (name="My and App"). This caused the downstream Python handler to receive malformed arguments, silently returning empty output. Replaced with a quote-aware splitter (split_tag_args) that preserves quoted strings as single arguments. (#419)
  • {% load %} tags stripped during template inheritance, breaking inclusion tags — The Rust parser treated {% load %} as Node::Comment, which nodes_to_template_string() discarded during inheritance reconstruction. When the resolved template was re-parsed, custom tags that relied on Django tag libraries (e.g. {% djust_pwa_head %}) could silently fail. Fixed by adding a dedicated Node::Load variant that preserves library names through reconstruction. Also improved _render_django_tag() error handling: failures now log a full traceback via logger.exception() and return a visible HTML comment instead of an empty string. (#418)
  • Checkbox/radio checked and <option> selected state not updated by VDOM patchesSetAttr and RemoveAttr patches only called setAttribute/removeAttribute, which updates the HTML attribute but not the DOM property. After user interaction the browser separates the two, so server-driven state changes via dj-click had no visible effect on checkboxes, radios, or select options. Fixed by syncing the DOM property alongside the attribute. Also fixed createNodeFromVNode to set .checked/.selected when creating new elements. (#422)
  • SESSION_TTL=0 breaks all event handling (no DOM patches)cleanup_expired() methods in both InMemoryStateBackend and RedisStateBackend now treat TTL ≤ 0 as "never expire". Previously SESSION_TTL=0 caused cutoff = time.time() - 0, making all sessions appear expired, deleting them immediately, and leaving no state for VDOM patches. (#395)
  • WebSocket session extraction crashes on Django Channels LazyObject — Replaced hasattr(scope_session, "session_key") with getattr(scope_session, "session_key", None) in the consumer's request context builder. hasattr() on a Django Channels LazyObject can raise non-AttributeError exceptions during lazy evaluation, causing the consumer to crash silently. (#396)

Deprecated

  • dj-params JSON blob attribute — Use individual data-* attributes with optional type-coercion suffixes instead. dj-params will be removed in a future release.

    Migration guide (0.3.2 → 0.3.6):

    <!-- Before (0.3.2) -->
    <button dj-click="start_edit" dj-params='{"todo_id": {{ todo.id }}}'>Edit</button>
    <button dj-click="set_filter" dj-params='{"filter_value": "all"}'>All</button>
    
    <!-- After (0.3.6+) -->
    <button dj-click="start_edit" data-todo-id:int="{{ todo.id }}">Edit</button>
    <button dj-click="set_filter" data-filter-value="all">All</button>
    

    Type-coercion suffixes: :int, :float, :bool, :json. Kebab-case attribute names are auto-converted to snake_case for server handler parameters.

Added

  • djust-deploy CLI — new python/djust/deploy_cli.py module providing deployment commands for djustlive.com. Available via the djust-deploy entry point after installation. (#437)

    • djust-deploy login — prompts for email/password, authenticates against djustlive.com, and stores the token in ~/.djustlive/credentials (mode 0o600)
    • djust-deploy logout — calls the server logout endpoint and removes the local credentials file
    • djust-deploy status [project] — fetches current deployment state; optionally filtered by project slug
    • djust-deploy deploy <project-slug> — validates the git working tree is clean, triggers a production deployment, and streams build logs to stdout
    • --server flag / DJUST_SERVER env var to override the default server URL (https://djustlive.com) See docs/website/guides/djust-deploy.md.
  • TypeScript type stubs updatedDjustStreamOp now includes "done" and "start" operation types and an optional mode field ("append" | "replace" | "prepend"). getActiveStreams() return type changed from Map to Record. See docs/website/guides/typecheck.md.

  • .flex-between CSS utility class — Added to demo project's utilities.css for laying out flex children horizontally with space-between. Use on card headers or any flex container that needs a title on the left and action widget on the right. (#397) See docs/website/guides/css-frameworks.md.

  • Debug toolbar state size visualization — New "Size Breakdown" table in State tab shows per-variable memory and serialized byte sizes with human-readable formatting (B/KB/MB). Added _debug_state_sizes() method to PostProcessingMixin included in both mount and event debug payloads. (#459)

  • Debug panel TurboNav persistence — Event, patch, network, and state history now persist across TurboNav navigation via sessionStorage (30s window). Panel state restores on next page if navigated within 30 seconds. (#459) See docs/website/advanced/debug-panel.md.

  • TurboNav integration guide — Comprehensive guide covering setup, navigation lifecycle, inline script handling, known caveats, and design decisions: docs/guides/turbonav-integration.md. (#459)

  • Debug panel search extended to Network and State tabs — The search bar in the debug panel now filters across all data tabs. The Network tab shows a N / total count label when a query narrows the message list (#530). The State tab filters history entries by trigger, event name, and serialized state content, with the same N / total count label (#520). Overlapping nameFilter and searchQuery on the Events tab now correctly apply AND semantics (#532). (#541)

    See docs/website/advanced/debug-panel.md.

[0.3.6rc4] - 2026-03-13

Fixed

  • Skip redundant mount() on WebSocket connect for pre-rendered pages — When the client sends has_prerendered=true on WS connect and saved state exists in the session (written during the HTTP GET), the view's attributes are restored from session instead of re-running mount(). This eliminates the double page-load cost for views with expensive mount() implementations (e.g. directory scans, API calls). Falls back to calling mount() normally when no saved state is found. _ensure_tenant() is now called unconditionally before the restore/mount decision, fixing a regression where multi-tenant views had self.tenant=None on WS connect for pre-rendered pages. (#542)

[0.3.6rc3] - 2026-03-13

Breaking Changes

  • model.id now returns the native type, not a string_serialize_model_safely() previously wrapped obj.pk with str() when producing the "id" key, causing template comparisons like {% if edit_id == todo.id %} to fail silently when edit_id was an integer. model.id now matches model.pk and returns the native Python type (e.g. int, UUID). Migration: if your templates or event handlers compare model.id against string literals or string-typed variables, update them to use the native type. PR #262 fixed .pk; this PR (#472) completes the fix for .id.

Fixed

  • djust cache --all now correctly clears all sessions on the Redis backend — The CLI called cleanup_expired(ttl=0) to force-clear sessions, but the semantics of ttl=0 changed in 0.3.5 to mean "never expire". The command now calls the explicit delete_all() method, which uses a Redis pipeline for an efficient single round-trip bulk delete. (#409)
  • dj-params attribute no longer silently dropped — Between 0.3.2 and 0.3.6rc2, dj-params was removed from the client event-binding code. Templates using dj-params='{"key": value}' continued to fire click events but the server received params: {}. The attribute is now read and merged into the params object for backward compatibility. A console.warn is emitted in debug mode (globalThis.djustDebug) to notify developers to migrate. (#469)
  • Prefetch Set not cleared on SPA navigation — The client-side _prefetched Set persisted across live_redirect navigations, preventing links on the new view from being prefetched. Added clear() to window.djust._prefetch and call it in handleLiveRedirect() so each SPA navigation starts with a fresh prefetch state. (#402)
  • Auto-reload on unrecoverable VDOM state — When VDOM patch recovery fails because recovery HTML is unavailable (e.g. after server restart), the client now auto-reloads the page instead of showing a confusing error overlay. The server sends recoverable: false to signal the client. (#421)
  • {% djust_pwa_head %} and other custom tags with quoted arguments containing spaces now render correctly — The Rust template lexer used split_whitespace() to tokenize tag arguments, which broke quoted values like name="My App" into separate tokens (name="My and App"). This caused the downstream Python handler to receive malformed arguments, silently returning empty output. Replaced with a quote-aware splitter (split_tag_args) that preserves quoted strings as single arguments. (#419)
  • {% load %} tags stripped during template inheritance, breaking inclusion tags — The Rust parser treated {% load %} as Node::Comment, which nodes_to_template_string() discarded during inheritance reconstruction. When the resolved template was re-parsed, custom tags that relied on Django tag libraries (e.g. {% djust_pwa_head %}) could silently fail. Fixed by adding a dedicated Node::Load variant that preserves library names through reconstruction. Also improved _render_django_tag() error handling: failures now log a full traceback via logger.exception() and return a visible HTML comment instead of an empty string. (#418)
  • Checkbox/radio checked and <option> selected state not updated by VDOM patchesSetAttr and RemoveAttr patches only called setAttribute/removeAttribute, which updates the HTML attribute but not the DOM property. After user interaction the browser separates the two, so server-driven state changes via dj-click had no visible effect on checkboxes, radios, or select options. Fixed by syncing the DOM property alongside the attribute. Also fixed createNodeFromVNode to set .checked/.selected when creating new elements. (#422)
  • SESSION_TTL=0 breaks all event handling (no DOM patches)cleanup_expired() methods in both InMemoryStateBackend and RedisStateBackend now treat TTL ≤ 0 as "never expire". Previously SESSION_TTL=0 caused cutoff = time.time() - 0, making all sessions appear expired, deleting them immediately, and leaving no state for VDOM patches. (#395)
  • WebSocket session extraction crashes on Django Channels LazyObject — Replaced hasattr(scope_session, "session_key") with getattr(scope_session, "session_key", None) in the consumer's request context builder. hasattr() on a Django Channels LazyObject can raise non-AttributeError exceptions during lazy evaluation, causing the consumer to crash silently. (#396)

Deprecated

  • dj-params JSON blob attribute — Use individual data-* attributes with optional type-coercion suffixes instead. dj-params will be removed in a future release.

    Migration guide (0.3.2 → 0.3.6):

    <!-- Before (0.3.2) -->
    <button dj-click="start_edit" dj-params='{"todo_id": {{ todo.id }}}'>Edit</button>
    <button dj-click="set_filter" dj-params='{"filter_value": "all"}'>All</button>
    
    <!-- After (0.3.6+) -->
    <button dj-click="start_edit" data-todo-id:int="{{ todo.id }}">Edit</button>
    <button dj-click="set_filter" data-filter-value="all">All</button>
    

    Type-coercion suffixes: :int, :float, :bool, :json. Kebab-case attribute names are auto-converted to snake_case for server handler parameters.

Added

  • djust-deploy CLI — new python/djust/deploy_cli.py module providing deployment commands for djustlive.com. Available via the djust-deploy entry point after installation. (#437)

    • djust-deploy login — prompts for email/password, authenticates against djustlive.com, and stores the token in ~/.djustlive/credentials (mode 0o600)
    • djust-deploy logout — calls the server logout endpoint and removes the local credentials file
    • djust-deploy status [project] — fetches current deployment state; optionally filtered by project slug
    • djust-deploy deploy <project-slug> — validates the git working tree is clean, triggers a production deployment, and streams build logs to stdout
    • --server flag / DJUST_SERVER env var to override the default server URL (https://djustlive.com) See docs/website/guides/djust-deploy.md.
  • TypeScript type stubs updatedDjustStreamOp now includes "done" and "start" operation types and an optional mode field ("append" | "replace" | "prepend"). getActiveStreams() return type changed from Map to Record. See docs/website/guides/typecheck.md.

  • .flex-between CSS utility class — Added to demo project's utilities.css for laying out flex children horizontally with space-between. Use on card headers or any flex container that needs a title on the left and action widget on the right. (#397) See docs/website/guides/css-frameworks.md.

  • Debug toolbar state size visualization — New "Size Breakdown" table in State tab shows per-variable memory and serialized byte sizes with human-readable formatting (B/KB/MB). Added _debug_state_sizes() method to PostProcessingMixin included in both mount and event debug payloads. (#459)

  • Debug panel TurboNav persistence — Event, patch, network, and state history now persist across TurboNav navigation via sessionStorage (30s window). Panel state restores on next page if navigated within 30 seconds. (#459) See docs/website/advanced/debug-panel.md.

  • TurboNav integration guide — Comprehensive guide covering setup, navigation lifecycle, inline script handling, known caveats, and design decisions: docs/guides/turbonav-integration.md. (#459)

  • Debug panel search extended to Network and State tabs — The search bar in the debug panel now filters across all data tabs. The Network tab shows a N / total count label when a query narrows the message list (#530). The State tab filters history entries by trigger, event name, and serialized state content, with the same N / total count label (#520). Overlapping nameFilter and searchQuery on the Events tab now correctly apply AND semantics (#532). (#541)

    See docs/website/advanced/debug-panel.md.

[0.3.5] - 2026-03-05

Added

  • djust-deploy CLI — new python/djust/deploy_cli.py module providing deployment commands for djustlive.com. Install with pip install djust[deploy]. Available via the djust-deploy entry point:

    • djust-deploy login — prompts for email/password, authenticates against djustlive.com, and stores the token in ~/.djustlive/credentials (mode 0o600)
    • djust-deploy logout — calls the server logout endpoint and removes the local credentials file
    • djust-deploy status [project] — fetches current deployment state; optionally filtered by project slug
    • djust-deploy deploy <project-slug> — validates the git working tree is clean, triggers a production deployment, and streams build logs to stdout

    See docs/website/guides/djust-deploy.md.

Fixed

  • dj-hook elements now initialize after dj-navigate navigationupdateHooks() is called after live_redirect_mount replaces DOM content via WebSocket and SSE mount handlers. Previously, hook lifecycle callbacks (mounted(), destroyed()) were skipped after client-side navigation, leaving hook-dependent elements (e.g., Chart.js canvases) uninitialized. (#408)
  • Event handler exceptions now logged with full traceback in production — Previously, handle_exception() only logged the exception class name (e.g. ValueError) when DEBUG=False, hiding the error message and stack trace. Now logs type, message, and traceback at ERROR level regardless of DEBUG mode. Client responses remain generic in production. (#415)
  • DJE-053 no longer fires as a warning for idempotent event handlers — When an @event_handler runs successfully but produces no DOM changes (e.g. toggle clicked in target state, debounced input with unchanged results, side-effect-only handlers), the empty diff is now silently dropped at DEBUG level rather than logged as a WARNING. This matches Phoenix LiveView behaviour. The WARNING-level DJE-053 is preserved for genuine VDOM failures (patches=None), which fall back to a full HTML update and risk losing event listeners. (#415)

[0.3.5rc2] - 2026-03-04

Fixed

  • VDOM patching with conditional {% if %} blocksInsertChild and RemoveChild patches now include ref_d and child_d fields for ID-based DOM resolution, preventing stale-index mis-targeting when {% if %} blocks add or remove elements that shift sibling positions. Falls back to index-based resolution for backwards compatibility. (#410)

[0.3.5rc1] - 2026-02-26

Added

  • Type stubs for Rust-injected LiveView methods.pyi stubs for live_redirect, live_patch, push_event, stream, and related methods so mypy/pyright catch typos at lint time. (#390) See docs/website/guides/typecheck.md.
  • Navigation Patterns guide — Documents when to use dj-navigate vs live_redirect vs live_patch. (#390)
  • Testing guide — Django testing best practices and pytest setup for djust applications. (#390) See docs/website/api-reference/testing.md.
  • System checks reference — New docs/system-checks.md covering all 37 check IDs (C/V/S/T/Q) with severity, detection method, suppression patterns, and known false positives. (#398)

Security

  • mark_safe(f"...") eliminated in core frameworkcomponents/base.py now uses format_html() to avoid XSS risk in component rendering. (#390)
  • Exception details no longer exposed in productionrender_template() previously returned f"<div>Error: {e}</div>" unconditionally, leaking internal Rust template engine details. Now returns a generic message in production; error details are only shown when settings.DEBUG = True. (#385)
  • Playground XSS fixed — Replaced innerHTML assignment with a sandboxed iframe for user-editable preview content. (#384)
  • Prototype pollution guard — Added safeguards against prototype pollution in client-side JS. (#384)

Fixed

  • {% if %} inside attribute values no longer shifts VDOM path indices — Conditional attribute fragments were causing off-by-one errors in VDOM diffing. (#390)
  • super().__init__() added to component and backend subclassesTenantAwareRedisBackend, TenantAwareMemoryBackend, and several example components were missing super().__init__() calls, causing MRO issues. (#386)
  • Unused escape import removed from data_table.py — CodeQL alert resolved. (#387)
  • render_full_template signature mismatch fixedno_template_demo.py override now correctly accepts serialized_context. (#387)
  • V004 false positives on lifecycle methodshandle_params(), handle_disconnect(), handle_connect(), and handle_event() no longer incorrectly trigger the V004 system check. (#398)
  • T013 false positives for {{ view_path }}dj-view="{{ view_path }}" (Django template variable injection) is now correctly recognised as valid by T013. (#398)
  • V008 false positives for -> str-annotated functions — Functions with primitive return-type annotations (e.g. -> str, -> int) no longer trigger V008 when their result is assigned in mount(). (#398)
  • Test isolationtest_checks.py and double_bind.test.js no longer fail when run as part of the full suite. (#390)

[0.3.4] - 2026-02-24

Stable release — promotes 0.3.3rc1 through 0.3.3rc3. All changes below were present in the RC series; this entry summarises them for the stable changelog.

Added

  • 6 new Django template tags in Rust renderer{% widthratio %}, {% firstof %}, {% templatetag %}, {% spaceless %}, {% cycle %}, {% now %}. (#329) See docs/website/guides/template-cheatsheet.md.
  • System checks djust.T011 / T012 / T013 — Warns at startup for unsupported Rust template tags, missing dj-view, and invalid dj-view paths. (#293, #329)
  • Deployment guides — Railway, Render, and Fly.io. (#247)
  • Navigation and LiveView invariants documentation. (#304, #316)

Fixed

  • #380: {% if %} in HTML attribute values no longer emits <!--dj-if--> comment — Produced malformed HTML (e.g. class="btn <!--dj-if-->"). Empty string is emitted instead; text-node VDOM anchor is unaffected. (#381)
  • #382: {% elif %} chains in attribute values propagate in_tag_context — All elif nodes in a chain now inherit the outer {% if %}'s attribute context. (#383)
  • {% if/else %} branches miscounting div depth in template extraction. (#365)
  • VDOM extraction used fully-merged {% extends %} document. (#366)
  • TypeError: Illegal invocation in debug panel on Chrome/Edge. (#367)
  • dj-patch('/') now correctly updates browser URL to root path. (#307)
  • live_patch routing restoredhandleNavigation dispatch now fires correctly. (#307)
  • T003 false positives eliminated{% include %} check now examines the include path, not whole-file content. (#331)

[0.3.3rc3] - 2026-02-24

Fixed

  • #382: {% elif %} inside HTML attribute values propagates in_tag_context — When {% if a %}...{% elif b %}...{% endif %} appears inside an attribute value and all conditions are false, the elif node previously emitted <!--dj-if--> (malformed HTML). Fixed by threading in_tag_context as a parameter into parse_if_block() so elif nodes inherit the outer if's attribute context. (#382)

[0.3.3rc2] - 2026-02-24

Fixed

  • {% if/else %} branches miscounting div depth in template extraction_extract_liveview_root_with_wrapper and the other extraction methods treated both branches of a {% if/else %} block as independent div opens, causing depth to never reach 0 when both branches opened a div sharing a single closing </div>. This caused the entire template to be returned as root, making the view non-reactive. Fixed with a shared _find_closing_div_pos() static method that uses a branch stack to restore depth at {% else %}/{% elif %} tags, so mutually-exclusive branches are counted as one open. (#365)
  • VDOM extraction used fully-merged {% extends %} document — For inherited templates, get_template() extracted the VDOM root from the fully-resolved document (base HTML + inlined blocks), which contains surrounding HTML that the depth counter could trip over. Now prefers the child template source when it contains dj-root/dj-view, which holds exactly the block content needed. Also fixes the exception fallback path: the raw child source (containing {% extends %}) was incorrectly stored in _full_template, causing render_full_template to attempt rendering a non-standalone template. (#366)
  • TypeError: Illegal invocation in debug panel on Chrome/Edge_hookExistingWebSocket called native WebSocket getter/setter functions via Function.prototype.call() from external code, which fails V8's brand check on IDL-generated bindings. Fixed by using normal property access (ws.onmessage) and assignment (ws.onmessage = handler) instead of desc.get/set.call(ws). (#367)

[0.3.3rc1] - 2026-02-21

Added

  • 6 new Django template tags in Rust renderer — Implemented {% widthratio %}, {% firstof %}, {% templatetag %}, {% spaceless %}, {% cycle %}, and {% now %} in the Rust template engine. These tags were previously rendered as HTML comments with warnings. (#329) See docs/website/guides/template-cheatsheet.md.
  • System check djust.T011 for unsupported template tags — Warns at startup when templates use Django tags not yet implemented in the Rust renderer (ifchanged, regroup, resetcycle, lorem, debug, filter, autoescape). Suppressible with {# noqa: T011 #}. (#329)
  • System check djust.T012 for missing dj-view — Detects templates that use dj-* event directives without a dj-view attribute, which would silently fail at runtime. (#293) See docs/website/getting-started/first-liveview.md.
  • System check djust.T013 for invalid dj-view paths — Detects empty or malformed dj-view attribute values. (#293) See docs/website/getting-started/first-liveview.md.
  • {% now %} supports 35+ Django date format specifiers — Including S (ordinal suffix), t (days in month), w/W (weekday/week number), L (leap year), c (ISO 8601), r (RFC 2822), U (Unix timestamp), and Django's special P format (noon/midnight).
  • Deployment guides — Added deployment documentation for Railway, Render, and Fly.io. (#247)
  • Navigation best practices documentation — Documented dj-patch vs dj-click for client-side navigation, with handle_params() patterns. (#304) See docs/guides/BEST_PRACTICES.md.
  • LiveView invariants documentation — Documented root container requirement and **kwargs convention for event handlers. (#316)

Fixed

  • #380: {% if %} inside HTML attribute values no longer emits <!--dj-if--> comment — When a {% if %} block with no else branch evaluates to false inside an HTML attribute value (e.g. class="btn {% if active %}active{% endif %}"), the Rust renderer now emits an empty string instead of the <!--dj-if--> VDOM placeholder. The placeholder is only meaningful as a DOM child node; inside an attribute it produced malformed HTML (e.g. class="btn <!--dj-if-->"). Text-node context is unaffected — the anchor comment is still emitted there for VDOM stability (fix for DJE-053 / #295).
  • False {% if %} blocks now emit <!--dj-if--> placeholder instead of empty string — Gives the VDOM diffing engine a stable DOM anchor to target when the condition later becomes true, resolving DJE-053 / issue #295.
  • dj-patch('/') now correctly updates the browser URL to the root path — Removed the url.pathname !== '/' guard in bindNavigationDirectives that prevented the browser URL from being updated when patching to /. The guard was silently ignoring root-path patches. (#307)
  • live_patch routing restored — handleNavigation dispatch now fires correctly — Fixed dict merge order in _flush_navigation so type: 'navigation' is no longer overwritten by **cmd. Added an action field to carry the nav sub-type (live_patch / live_redirect); handleNavigation now dispatches on data.action instead of data.type. Previously the client switch case 'navigation': never matched because type was being overwritten with "live_patch". Note: data.action || data.type fallback is kept for old JS clients that send messages without an action field — this fallback is planned for removal in the next minor release. (#307)
  • T003 false positives eliminated — The {% include %} check now examines the include path instead of the whole file content, preventing false warnings on templates that include SVGs or modals alongside dj-* directives. (#331)

[0.3.2] - 2026-02-18

Added

  • TypeScript definitions (djust.d.ts) — Comprehensive ambient TypeScript declaration file shipped with the Python package at static/djust/djust.d.ts. Covers: window.djust namespace, LiveViewWebSocket and LiveViewSSE transport classes, DjustHook lifecycle interface (mounted, beforeUpdate, updated, destroyed, disconnected, reconnected), DjustHookContext (this.el, this.pushEvent, this.handleEvent), dj-model binding types, streaming API types (DjustStreamMessage, DjustStreamOp), upload progress event types (DjustUploadEntry, DjustUploadConfig, DjustUploadProgressEventDetail), and the djust:upload:progress custom DOM event. Use via /// <reference path="..." /> or add to tsconfig.json.
  • Python type stubs (_rust.pyi) — PEP 561 compliant type stubs for the PyO3 Rust extension module (djust._rust). Covers all exported functions (render_template, render_template_with_dirs, diff_html, resolve_template_inheritance, fast_json_dumps, serialization helpers, tag handler registry) and classes (RustLiveView, SessionActorHandle, SupervisorStatsPy, and all 15 Rust UI components). Enables full IDE autocomplete and mypy type checking for the Rust extension.
  • SSE (Server-Sent Events) fallback transport — djust now automatically falls back to SSE when WebSocket is unavailable (corporate proxies, enterprise firewalls). Architecture: EventSource for server→client push, HTTP POST for client→server events. Transport negotiation is automatic: WebSocket is tried first; SSE activates after all reconnect attempts fail. Register the endpoint with path("djust/", include(djust.sse.sse_urlpatterns)) and include 03b-sse.js in your template. Feature limitations: no binary file uploads, no presence tracking, no actor-based state. See docs/sse-transport.md for full setup guide.
  • Type stub files (.pyi) for LiveView and mixins — Added PEP 561 compliant type stubs for NavigationMixin, PushEventMixin, StreamsMixin, StreamingMixin, and LiveView to enable IDE autocomplete and mypy type checking for runtime-injected methods like live_redirect, live_patch, push_event, stream, stream_insert, stream_delete, and stream_to. Includes py.typed marker file and comprehensive test suite.
  • @background decorator for async event handlers — New decorator that automatically runs the entire event handler in a background thread via start_async(). Simplifies syntax for long-running operations (AI generation, API calls, file processing) without needing explicit callback splitting. Can be combined with other decorators like @debounce. Task name is automatically set to the handler's function name for cancellation tracking. (#313)
  • start_async() keeps loading state active during background work — WebSocket responses include async_pending flag when a start_async() callback is running, preventing loading spinners from disappearing prematurely. Async completion responses include event_name so the client clears the correct loading state. Supports named tasks for tracking and cancellation via cancel_async(name). Optional handle_async_result(name, result, error) callback for completion/error handling. (#313, #314) See docs/website/guides/loading-states.md.
  • dj-loading.for attribute — Scope any dj-loading.* directive to a specific event name, regardless of DOM position. Allows spinners, disabled buttons, and other loading indicators anywhere in the page to react to a named event. (#314)
  • AsyncWorkMixin included in LiveView base classstart_async() is now available on all LiveViews without explicit mixin import. (#314) See docs/website/guides/loading-states.md.
  • Loading state re-scan after DOM patchesscanAndRegister() is called after every bindLiveViewEvents() so dynamically rendered elements (e.g., inside modals) get loading state registration. Stale entries for disconnected elements are cleaned up automatically. (#314) See docs/website/guides/loading-states.md.
  • System check djust.T010 for dj-click navigation antipattern — Detects elements using dj-click with navigation-related data attributes (data-view, data-tab, data-page, data-section). This pattern should use dj-patch instead for proper URL updates, browser history support, and bookmarkable views. Warning severity. (#305)
  • System check djust.Q010 for navigation state in event handlers — Heuristic INFO-level check that detects @event_handler methods setting navigation state variables (self.active_view, self.current_tab, etc.) without using patch() or handle_params(). Suggests converting to dj-patch pattern for URL updates and back-button support. Can be suppressed with # noqa: Q010. (#305)
  • Type stubs for Rust extension and LiveView — Added .pyi type stub files for _rust module and LiveView class, enabling IDE autocomplete, mypy/pyright type checking, and catching typos like live_navigate (should be live_patch) at lint time. Includes py.typed marker for PEP 561 compliance and comprehensive documentation in docs/TYPE_STUBS.md.

Deprecated

  • data.type fallback in handleNavigation — The data.action || data.type fallback for pre-#307 clients (added for backwards compatibility in #318) will be removed in the next minor release. Server now sends data.action on all navigation messages. Update any custom client code that sends navigation messages without an action field.

Fixed

  • Silent str() coercion for non-serializable LiveView state — Non-serializable objects stored in self.* during mount() (e.g., service instances, API clients) were silently converted to strings, causing confusing AttributeError on subsequent requests far from the root cause. normalize_django_value() now logs a warning before falling back with the type name, module, and guidance on how to fix. Opt-in strict mode (DJUST_STRICT_SERIALIZATION = True) raises TypeError instead of coercing, recommended for development. New static check djust.V008 (AST-based) detects non-primitive assignments in mount() at development time. (#292)
  • System check S005 incorrectly warns on views with login_required = False — The S005 security check now correctly distinguishes between intentionally public views (login_required = False) and views that haven't addressed authentication at all (login_required = None). Previously, views with login_required = False were incorrectly flagged as missing authentication due to a truthy test. The check now uses explicit is not None comparisons to distinguish intentional public access from unaddressed auth. (#303)
  • |safe filter rendering empty string for nested SafeString values — When mark_safe() HTML was stored in lists of dicts or nested dicts, the |safe filter rendered an empty string instead of preserving the HTML. The _collect_safe_keys() function now recursively scans nested dicts and lists using dotted path notation (e.g., "items.0.content") to track all SafeString locations. Includes circular reference protection to prevent RecursionError on tree/graph structures. (#317)
  • VDOM diff incorrectly matching siblings when {% if %} removes nodes — When {% if %} blocks evaluated to false and removed elements, siblings shifted left, causing diff_indexed_children() to incorrectly match unrelated nodes and generate wrong patches. The template engine now emits <!--dj-if--> placeholder comments when conditions are false (matching Phoenix LiveView's approach), maintaining consistent sibling positions. The VDOM diff detects placeholder-to-content transitions and generates RemoveChild + InsertChild patches instead of Replace patches for semantic consistency. Eliminates DJE-053 fallback to full HTML updates and removes need for style='display:none' workarounds. (#295)
  • Event listener leak causing duplicate WebSocket sends — Single user actions were triggering the same event multiple times (e.g. select_project 5×, mount 3×) because listeners accumulated across VDOM patch/morph cycles without cleanup. Fixed four root causes: (1) initReactCounters now uses a WeakSet guard to skip already-initialized containers; (2) createNodeFromVNode no longer pre-marks elements as bound before bindLiveViewEvents() runs, eliminating a race where newly inserted elements were silently skipped; (3) dj-click handlers now read the attribute at fire-time rather than bind-time, so morphElement attribute updates take effect immediately; (4) three unguarded console.log calls in 12-vdom-patch.js are now wrapped in if (globalThis.djustDebug). The existing WeakMap-based deduplication in bindLiveViewEvents() (introduced in #312) correctly prevents re-binding when called repeatedly. (#315)
  • dj-patch('/') failed to update URL and live_patch routing broken — Removed url.pathname !== '/' guard in bindNavigationDirectives so root-path navigation works. Fixed dict merge order in _flush_navigation so server sends type='navigation' instead of type='live_patch'. Updated handleNavigation to dispatch via data.action with data.action || data.type fallback for backwards compatibility. (#318)
  • 52 unguarded console.log calls in client JS — All console.log calls across 12 files in static/djust/src/ (excluding the intentional debug panel in src/debug/) are now wrapped with if (globalThis.djustDebug). Bare logging in production code leaks internal state to browser consoles and violates the djust.Q003 system check. Files affected: 00-namespace.js, 02-response-handler.js, 03-websocket.js, 04-cache.js, 05-state-bus.js, 06-draft-manager.js, 07-form-data.js, 09-event-binding.js, 10-loading-states.js, 11-event-handler.js, 12-vdom-patch.js, 13-lazy-hydration.js.
  • dj-submit forms sent empty params when created by VDOM patchescreateNodeFromVNode now correctly collects FormData for submit events; replaced data-liveview-*-bound attribute tracking with WeakMap to prevent stale binding flags after DOM replacement (#312)

Security

  • F-strings in logging calls — Converted 9 logger calls to use %-style formatting (logger.error("msg %s", val)) instead of f-strings (logger.error(f"msg {val}")). F-strings defeat lazy evaluation, causing string interpolation before the log level check, potentially exposing sensitive data and wasting CPU. Affected files: mixins/template.py, security/__init__.py, security/error_handling.py, template_tags/__init__.py, template_tags/static.py, template_tags/url.py.

Tests

  • Regression tests for |safe filter with nested dicts — Added comprehensive tests verifying that |safe filter works correctly for HTML content in nested dict/list values, preventing issue #317 from recurring

[0.3.2rc1] - 2026-02-15

Fixed

  • Form data lost on dj-submit — Client-only properties (_targetElement, _optimisticUpdateId, _skipLoading, _djTargetSelector) are now stripped from event params before serialization. Previously, HTMLFormElement references in params corrupted the JSON payload, overwriting form field data with the element's indexed children. (#308)
  • @changedj-change in form adapters — All three framework adapters (Bootstrap 5, Tailwind, Plain) rendered @change="validate_field" instead of dj-change="validate_field", causing real-time field validation to silently fail. (#310)
  • EmailField rendered as type="text"_get_field_type() checked CharField before EmailField (which inherits from CharField), so email fields never got type="email". Reordered the isinstance checks. (#310)

Security

  • XSS in FormMixin.render_field() — Removed render_field(), _render_field_widget(), and _attrs_to_string() from FormMixin. These methods used f-strings with no escaping to build HTML, allowing stored XSS via form field values. Use as_live() / as_live_field() (which delegate to framework adapters with proper escape()) instead. (#310)
  • Textarea content not escaped in adapters_render_input() passed raw textarea values to _build_tag() content without escape(). Added escape(str(value)) for textarea content. (#310)

Changed

  • Framework adapters deduplicated — Created BaseAdapter with all shared rendering logic. Bootstrap5Adapter, TailwindAdapter, and PlainAdapter reduced from ~200 lines each to ~10 lines of class attributes. frameworks.py reduced from ~657 to ~349 lines. (#310)
  • _model_instance support for ModelForm editingFormMixin.mount() now reads field values from _model_instance if set and the form is a ModelForm. _create_form() passes instance= to the form constructor. (#310)

Deprecated

  • LiveViewForm — Emits DeprecationWarning on subclass. Adds no functionality over django.forms.Form. Will be removed in 0.4. (#310)

Removed

  • FormMixin.render_field() — Insecure (XSS via f-strings) and duplicated adapter logic. Use as_live_field() instead. (#310)
  • form_field() function — Dead code, never called. Removed from forms.py and __all__. (#310)

[0.3.1] - 2026-02-14

Changed

  • 3.8x faster rendering for large pages — Optimized get_context_data() by replacing dir(self) iteration (~300 inherited Django View attributes, ~50ms) with targeted __dict__ + MRO walk (<1ms). Added dj-update="ignore" optimization to Rust VDOM diff engine, skipping subtrees the client won't patch (240ms → 17ms). Combined with template-level optimizations, reduces event roundtrip from ~160ms to ~42ms on pages with large static content.

0.3.0 - 2026-02-14

Added

  • dj-confirm attribute — Declarative confirmation dialogs for event handlers. Add dj-confirm="Are you sure?" to any dj-click element to show a browser confirmation dialog before dispatching the event. (#302)

  • CSS Framework Support — Comprehensive Tailwind CSS integration with three-part system: (1) System checks (djust.C010, djust.C011, djust.C012) automatically warn about Tailwind CDN in production, missing compiled CSS, and manual client.js loading. (2) Graceful fallback auto-injects Tailwind CDN in development mode when output.css is missing. (3) CLI helper command python manage.py djust_setup_css tailwind creates input.css with Tailwind v4 syntax, auto-detects template directories, finds Tailwind CLI, and builds CSS with optional --watch and --minify flags. Eliminates duplicate client.js race conditions and guides developers toward production-ready setup.

    See docs/website/guides/css-frameworks.md.

Fixed

  • Server-side template processing now auto-infers dj-root from dj-view — All template extraction methods (_extract_liveview_content, _extract_liveview_root_with_wrapper, _extract_liveview_template_content, _strip_liveview_root_in_html) now fall back to [dj-view] when [dj-root] is not present, matching the client-side autoStampRootAttributes() behavior introduced in PR #297. This fixes a bug where templates with only dj-view (no explicit dj-root) would fail to render correctly. (#300)
  • Client-side autoMount now correctly reads dj-view attribute — Fixed autoMount() to use getAttribute('dj-view') instead of container.dataset.djView. The dataset API reads data-* attributes, but dj-view is not a data attribute, causing the attribute to be missed. (#300)
  • System check T002 downgraded from WARNING to INFO — Since dj-root is now optional and auto-inferred from dj-view (per PR #297), the T002 check is now informational rather than a warning. The message now clarifies that auto-inference is working correctly. (#300)
  • Duplicate client.js loading race condition — djust now automatically detects and warns (via djust.C012 system check) when base or layout templates manually include <script src="{% static 'djust/client.js' %}">. Since the framework auto-injects client.js, manual loading causes double-initialization and console warnings. The check provides clear guidance to remove manual script tags.
  • Tailwind CDN in production — New djust.C010 system check warns when Tailwind CDN (cdn.tailwindcss.com) is detected in production templates (DEBUG=False). Provides actionable guidance to compile CSS with djust_setup_css command or Tailwind CLI. Prevents slow CDN performance and console warnings in production.

Security

  • Pre-Release Security Audit Process — Comprehensive security infrastructure to prevent vulnerabilities like the mount handler RCE (Issue #298) from reaching production. Includes 259 new security tests (Python + Rust) covering parameter injection, file upload attacks, URL injection, and XSS prevention across all contexts. Three GitHub workflows provide automated security scanning (bandit, safety, cargo-audit, npm audit, CodeQL), hot spot detection (auto-labels PRs touching security-sensitive code), and CI security test job requiring 85% coverage for security-sensitive modules. New pre-release security audit template with 7-phase checklist ensures comprehensive review before each release. Documentation updates establish mandatory security gates and review requirements for changes to hot spot files.

Dependencies

  • Bump happy-dom from 20.5.3 to 20.6.1 (#289)
  • Bump tempfile from 3.24.0 to 3.25.0 (#288)

[0.3.0rc5] - 2026-02-11

Added

  • Automatic change tracking — Phoenix-style render optimization. The framework automatically detects which context values changed between renders and only sends those to Rust's update_state(). Replaces the manual static_assigns API. Two-layer detection: snapshot comparison for instance attributes, id() reference comparison for computed values (e.g., @lru_cache results). Immutable types (str, int, float, bool, None, bytes, tuple, frozenset) skip deepcopy in snapshots.

Removed

  • static_assigns class attribute — Replaced by automatic change tracking. The framework now detects unchanged values automatically — no manual annotation needed.

[0.3.0rc4] - 2026-02-11

Added

  • All 57 Django template filters — The Rust template engine now supports the complete set of Django built-in filters. Added 24 filters across two batches: default_if_none, wordcount, wordwrap, striptags, addslashes, ljust, rjust, center, make_list, json_script, force_escape, escapejs, linenumbers, get_digit, iriencode, urlize, urlizetrunc, truncatechars_html, truncatewords_html, safeseq, escapeseq, unordered_list, phone2numeric, pprint. (#246, #254) See docs/website/guides/template-cheatsheet.md.
  • Authentication & Authorization — Opinionated, framework-enforced auth for LiveViews. View-level login_required and permission_required class attributes (plus LoginRequiredMixin/PermissionRequiredMixin for Django-familiar patterns). Custom auth logic via check_permissions() hook. Handler-level @permission_required() decorator for protecting individual event handlers. Auth checks run server-side before mount() and before handler dispatch — no client-side bypass possible. Integrates with djust_audit command (shows auth posture per view) and Django system checks (djust.S005 warns on unprotected views with exposed state).
  • Navigation & URL Statelive_patch() updates URL query params without remount, live_redirect() navigates to a different view over the same WebSocket. Includes handle_params() callback, live_session() URL routing helper, and client-side dj-patch/dj-navigate directives with popstate handling. (#236)
  • Presence Tracking — Real-time user presence with PresenceMixin and PresenceManager. Pluggable backends (in-memory and Redis). Includes LiveCursorMixin and CursorTracker for collaborative live cursor features. (#236) See docs/website/guides/presence.md.
  • StreamingStreamingMixin for real-time partial DOM updates (e.g., LLM token-by-token streaming). Provides stream_to(), stream_insert(), stream_text(), stream_error(), stream_start()/stream_done(), and push_state(). Batched at ~60fps to prevent flooding. (#236) See docs/website/guides/streaming-markdown.md.
  • File UploadsUploadMixin with binary WebSocket frame protocol for chunked file uploads. Includes progress tracking, magic bytes validation, file size/extension/MIME checking, and client-side dj-upload/dj-upload-drop directives. (#236) See docs/website/guides/uploads.md.
  • JS Hooksdj-hook attribute for client-side JavaScript lifecycle hooks (mounted, updated, destroyed, disconnected, reconnected). (#236)
  • Model Bindingdj-model two-way data binding with .lazy and .debounce-N modifiers. Server-side ModelBindingMixin with security field blocklist and type coercion. (#236) See docs/website/guides/model-binding.md.
  • Client Directivesdj-confirm confirmation dialogs, dj-target scoped updates, embedded view routing in event handlers. (#236)
  • Server-Push API — Background tasks (Celery, management commands, cron jobs) can now push state updates to connected LiveView clients via push_to_view(). Includes per-view channel groups (auto-joined on mount), a sync/async public API (push_to_view / apush_to_view), and periodic handle_tick() for self-updating views. (#230)
  • Progressive Web App (PWA) Support — Complete offline-first PWA implementation with service worker integration, IndexedDB/LocalStorage abstraction, optimistic UI updates, and offline-aware template directives. Includes comprehensive template tags ({% djust_pwa_head %}, {% djust_pwa_manifest %}), PWA mixins (PWAMixin, OfflineMixin, SyncMixin), and automatic synchronization when online. (#235) See docs/website/guides/pwa.md.
  • Multi-Tenant SaaS Support — Production-ready multi-tenant architecture with flexible tenant resolution strategies (subdomain, path, header, session, custom, chained), automatic data isolation, tenant-aware state backends, and comprehensive template context injection. Includes TenantMixin and TenantScopedMixin for views. (#235)
  • dj-poll attribute — Declarative polling for LiveView elements. Add dj-poll="handler_name" to any element to trigger the handler at regular intervals. Configurable via dj-poll-interval (default: 5000ms). Automatically pauses when the page is hidden and resumes on visibility change. (#269)
  • DjustMiddlewareStack — New ASGI middleware for apps that don't use django.contrib.auth. Wraps WebSocket routes with session middleware only (no auth required). Updated C005 system check to recognize both AuthMiddlewareStack and DjustMiddlewareStack. (#265)
  • System check C006 — Warns when daphne is in INSTALLED_APPS but whitenoise middleware is missing. (#259)
  • startproject / startapp / new CLI commandspython -m djust new myapp creates a full project with optional features (--with-auth, --with-db, --with-presence, --with-streaming, --from-schema). Legacy startproject and startapp commands also available. (#266)
  • djust mcp install CLI command — Automates MCP server setup for Claude Code, Cursor, and Windsurf. Tries claude mcp add first (canonical for Claude Code), falls back to writing .mcp.json directly. Merges with existing config, backs up malformed files, idempotent. See docs/website/guides/mcp-server.md.
  • Simplified root elementdj-view is now the only required attribute on LiveView container elements. The client auto-stamps dj-root and dj-liveview-root at init time. Old three-attribute format still works. (#258)
  • Model .pk in templates{{ model.pk }} now works in Rust-rendered templates. Model serialization includes a pk key with the native primary key value. (#262) See docs/website/guides/template-cheatsheet.md.
  • Better Error Messages — Improved error messages for common LiveView event handler mistakes (missing @event_handler, wrong method signature). (#248) See docs/website/guides/flash-messages.md.
  • LiveViewSmokeTest mixin — Automated smoke and fuzz testing for LiveView classes. (#251)
  • MCP serverpython manage.py djust_mcp starts a Model Context Protocol server for AI assistant integration. Provides framework introspection, system checks, scaffolding, and validation tools. Used by djust mcp install to configure Claude Code, Cursor, and Windsurf. See docs/website/guides/mcp-server.md.
  • djust_audit management command — Security audit showing auth posture, exposed state, and handler signatures per view.
  • djust_check management command — Django system checks for project validation. Gains --fix flag for safe auto-fixes and --format json for enhanced output with fix hints.
  • djust_schema management command — Extract and generate Django models from JSON schema files. See docs/guides/djust-audit.md.
  • djust_ai_context management command — Generate AI-focused context files for LLM integrations. See docs/guides/djust-audit.md.
  • AI documentationdocs/ai/ with focused guides for events, forms, JIT, lifecycle, security, and templates. docs/llms.txt and docs/llms-full.txt for LLM context.
  • Auto-build client.js from src/ modules — Pre-commit hook runs build-client.sh when src/ files change. (#211)
  • Keyed-mutation fuzz test generator — New proptest generator produces tree B by mutating tree A, exercising keyed diff paths more effectively. Proptest cases bumped from 500 to 1000. (#216, #217)

Changed

  • BREAKING: data-dj-* prefix stripping — Client-side extractTypedParams() now strips the dj_ prefix from data-dj-* attributes. data-dj-preset="dark" sends {preset: "dark"} instead of {dj_preset: "dark"}. Update handler parameter names accordingly: dj_foofoo.
  • State Backends — Enhanced with tenant-aware isolation support (TenantAwareRedisBackend, TenantAwareMemoryBackend).

Performance

  • Batched sync_to_async calls — Event handler processing now uses 2 thread hops instead of 4, saving ~1-4ms per event. (#277)
  • Eliminated JSON encode/decode roundtrip — Direct normalize_django_value() Python-to-Python type normalization replaces 17 json.loads(json.dumps(...)) patterns. Saves 2-5ms per event for views with database objects. (#279)
  • Cached template variable extraction — Rust extract_template_variables() results cached by content hash (SHA-256). Size-capped at 256 entries with automatic eviction. (#280)
  • Cached context processor resolutionresolve_context_processors() results cached per settings configuration. Invalidated on setting_changed signal. (#281)
  • JIT short-circuit for non-DB views — Views without QuerySets or Models in context skip the entire JIT serialization pipeline. Saves ~0.5ms per event for simple views. (#278)
  • Slimmer debug payload — Event responses send only state variables; handler metadata moved to initial mount as static data. ~68% smaller debug payloads (~25KB → ~8KB per event).

Fixed

  • Inline args on form eventsdj-change, dj-input, dj-blur, dj-focus now parse inline arguments (e.g., dj-change="toggle(3)") before sending to server. Also fixed state change detection to use deep copy comparison, catching in-place mutations.
  • Error overlay on intentional disconnect — Suppress "WebSocket Connection Failed" overlay during TurboNav navigation via _intentionalDisconnect flag.
  • VDOM patch failure recovery — When VDOM patches fail, the client requests recovery HTML on demand instead of reloading the page. Uses DOM morphing to preserve event listeners and form state. (#259)
  • HTTP Fallback Protocolpost() now accepts the HTTP fallback format where the event name is in the X-Djust-Event header and params are flat in the body JSON. (#255)
  • Debug panel HTTP-only mode — POST responses include _debug payload when DEBUG=True, enabling the debug panel in HTTP-only mode. (#267)
  • Silent LiveView config failures — Client JS now shows helpful console.error when no LiveView containers are found. Added system check V005 for modules not in LIVEVIEW_ALLOWED_MODULES. (#257)
  • HTTP-only mode session state on GETget() now saves view state to the session immediately when use_websocket: False. (#264)
  • use_websocket: False client-side enforcement — Setting now actually prevents WebSocket connections. (#260)
  • DOM morphing preserves event listenershtml_update now uses morphdom-style DOM diffing instead of innerHTML. (#236)
  • Textarea newlines preserved — Template whitespace stripping no longer collapses newlines inside <textarea> elements. (#236)
  • PresenceMixin crash without authtrack_presence() now checks for request.user before accessing it. (#236)
  • _skip_render support in server_pushserver_push() now checks _skip_render, preventing phantom renders and VDOM version mismatches. (#236)
  • Client-side SetText mis-targets after keyed MoveChild — MoveChild patches now include child_d for data-dj-id resolution. (#225)
  • VDOM diff/patch round-trip on keyed child reorder — Patches now processed level-by-level (shallowest parent first). (#212)
  • apply_patches djust_id-based resolution — Resolves parent nodes by djust_id instead of path-based traversal. (#216)
  • Diff engine keyed+unkeyed interleaving — Emits MoveChild patches for unkeyed element children in keyed contexts. (#219)
  • Text node targeting after keyed movesSetText patches carry djust_id when available; sync_ids propagates IDs to text nodes. (#221)
  • Tag registry test pollutionclear_tag_handlers() now restores built-in handlers in teardown. (#261)

Security

  • HTTP POST handler dispatch gatingpost() now enforces the same security model as the WebSocket path: only @event_handler-decorated methods can be invoked. Validates event names with is_safe_event_name() to block dunders and private methods.
  • Auto-escaping in Rust template engineSafeString values propagated to Rust for proper auto-escaping.
  • HTML-escaped urlize and unordered_list filters — Both filters now escape their output to prevent XSS. (#254)
  • Template tag XSS prevention — All PWA template tags now use format_html() and escape() instead of mark_safe() with f-string interpolation.
  • Sync endpoint hardening — Removed @csrf_exempt from sync_endpoint_view. Added authentication requirement, payload validation, and safe field extraction.
  • Silent exception elimination — All except: pass patterns replaced with appropriate logging calls.
  • Production JS hardened — All console.log calls guarded behind djustDebug flag.

Removed

  • _allowed_events class attribute — The backwards-compatibility escape hatch that allowed undecorated methods to be called via WebSocket or HTTP POST has been removed. All event handlers must now use the @event_handler decorator.

0.2.2 - 2026-02-01

Fixed

  • Stale Closure Args on VDOM-Patched Elements — After deleting a todo, the remaining button's click handler sent the wrong _args (stale closure from bind time) because SetAttribute patches updated the dj-click DOM attribute but not the listener closure. Event listeners now re-parse dj-* attributes from the DOM at event time. Also sets dj-* as DOM attributes in createNodeFromVNode and marks elements as bound to prevent duplicate listeners. (#205)
  • VDOM: Non-breaking Space Text Nodes Stripped — Rust parser stripped &nbsp;-only text nodes (used in syntax highlighting) because char::is_whitespace() includes U+00A0. Now preserves \u00A0 text nodes in parser, to_html(), and client-side path traversal. Also adds sync_ids() to prevent ID drift between server VDOM and client DOM after diffing, and 4-phase patch ordering matching Rust's apply_patches(). (#199)
  • CSRF Token Lookup on Formless Pages — Pages without a <form> element failed to send CSRF tokens with WebSocket events. Token lookup now falls back to the csrftoken cookie. (#210)
  • Codegen Crash on Numeric Index Paths — Template expressions like {{ posts.0.url }} produced paths starting with a numeric index (0.url), generating invalid Python (obj.0). Codegen now skips numeric-leading paths since list items are serialized individually.
  • JIT Serialization Pipeline — Fixed multiple issues in JIT auto-serialization: (#140)
    • M2M .all() traversal now generates correct iteration code in codegen serializers
    • @property attributes are now serialized via Rust→Python codegen fallback when Rust can't access them
    • list[Model] context values (not just QuerySets) now receive full JIT optimization with select_related/prefetch_related
    • Nested dicts containing Model/QuerySet values are now deep-serialized recursively
    • _djust_annotations model class attribute for declaring computed annotations (e.g., Count) applied during query optimization
    • {% include %} templates are now inlined for variable extraction, so included template variables get JIT optimization
    • Rust template parser now correctly prefixes loop variable paths (e.g., item.field inside {% for item in items %})
  • {% include %} After Cache Restoretemplate_dirs was not included in msgpack serialization of RustLiveView. After a cache hit, the restored view had empty search paths, causing {% include %} tags to fail with "Template not found". Now calls set_template_dirs() on both WebSocket and HTTP cache-hit paths.
  • VDOM Replace Sibling Grouping — Fixed data-djust-replace inserting children into wrong parent when the replace container has siblings. groupPatchesByParent() now uses the full path for child-operation patches, and groupConsecutiveInserts() checks parent identity before batching. (#144)
  • VDOM Replace Child Removal — Fixed data-djust-replace not removing old children before inserting new ones, causing duplicate content on re-render. (#142, #143)
  • Context Processor Precedence — View context now takes precedence over context processors. Previously, context processors could overwrite view-defined variables (e.g., Django's messages processor overwriting a view's messages variable).
  • VDOM Keyed Diff Insert Ordering — Fixed apply_patches for keyed diff insert ordering where items were inserted in the wrong position. (#154)
  • VDOM MoveChild Resolution — Fixed MoveChild in apply_patch by resolving children via djust_id instead of index. (#150)
  • Debug Toolbar: Received WebSocket Messages Not Captured — Network tab now captures both sent and received WebSocket messages by intercepting the onmessage property setter (not just addEventListener). (#188)
  • Debug Toolbar: Events Tab Always Empty — Events tab now populates by extracting event data from sent WebSocket messages and matching responses, replacing the broken window.liveView hook. (#188)
  • Debug Panel: Handler Discovery, Auto-loading, Tab Crashes — Handler discovery now finds all public methods; debug-panel.js auto-loads; handler dict normalized to array; retroactive WebSocket hooking for late-loading panels. (#191, #197)

Added

  • Debug Panel: Live Debug Payload — When DEBUG=True, WebSocket event responses now include a _debug field with updated variables, handlers, patches, and performance metrics. (#191) See docs/website/advanced/debug-panel.md.
  • Debug Toolbar: Event Filtering — Events tab filter controls to search by event/handler name and filter by status. (#180)
  • Debug Toolbar: Event Replay — Replay button (⟳) that re-sends events through the WebSocket with original params. (#181)
  • Debug Toolbar: Scoped State Persistence — Panel UI state scoped per view class via localStorage. (#182)
  • Debug Toolbar: Network Message Inspection — Directional color coding and copy-to-clipboard for expanded payloads. (#183)
  • Debug Toolbar: Test Harness — Integration tests against the actual DjustDebugPanel class. (#185)
  • VDOM Proptest/Fuzzing — Property-based testing for the VDOM diff algorithm with proptest. (#153)
  • Duplicate Key Detection — VDOM keyed diff now warns on duplicate keys. (#149)
  • Branding Assets — Official logo variants (dark, light, icon, wordmark, transparent). (#208, #213)

Deprecated

  • @event decorator alias — The @event shorthand is deprecated in favor of @event_handler. @event will be removed in v0.3.0. A deprecation warning is emitted at import time. (#141)

Changed

  • Internal: LiveView Mixin Extraction — Refactored monolithic live_view.py into focused mixins: RequestMixin, ContextMixin, JITMixin, TemplateMixin, RustBridgeMixin, ComponentMixin, LifecycleMixin. No public API changes. (#130)
  • Internal: Module Splits — Split client.js into source modules with concat build, extracted websocket_utils.py, session_utils.py, serialization.py, split state_backend.py into state_backends package, split template_backend.py into template package. (#124, #125, #126, #128, #129)
  • Dependencies — Upgraded uuid 1.19→1.20, thiserror 1→2, bincode 1→2, happy-dom 20.3.7→20.4.0, actions/setup-python 5→6, actions/upload-artifact 4→6, actions/checkout 4→6, softprops/action-gh-release 1→2

0.2.1 - 2026-01-29

Security

  • WebSocket Event Security Hardening - Three-layer defense for WebSocket event dispatch: (#104)
    • Event name guard — regex pattern filter (^[a-z][a-z0-9_]*$) blocks private methods, dunders, and malformed names before getattr()
    • @event_handler decorator allowlist — only methods decorated with @event_handler (or listed in _allowed_events) are callable via WebSocket. Configurable via event_security setting ("strict" default, "warn", "open")
    • Server-side rate limiting — per-connection token bucket algorithm with configurable rate/burst. Per-handler @rate_limit decorator for expensive operations. Automatic disconnect after repeated violations (close code 4429)
    • Per-IP connection limit — process-level IPConnectionTracker enforces a maximum number of concurrent WebSocket connections per IP (default: 10) and a reconnection cooldown after rate-limit disconnects (default: 5 seconds). Configurable via max_connections_per_ip and reconnect_cooldown in rate_limit settings. Supports X-Forwarded-For header for proxied deployments. (#108, #121)
    • Message size limit — 64KB default (max_message_size setting)

Documentation

  • Added migration guide for @event_handler decorator requirement and strict mode upgrade path (#105, #122)
  • Added @event_handler decorator to all example demo view handler methods

Added

  • is_event_handler(func) — check if a function is decorated with @event_handler
  • @rate_limit(rate, burst) — per-handler server-side rate limiting decorator
  • _allowed_events class attribute — escape hatch for bulk allowlisting without decorating each method
  • LIVEVIEW_CONFIG settings: event_security, rate_limit (including max_connections_per_ip, reconnect_cooldown), max_message_size

0.2.0 - 2026-01-28

Added

  • Template and/or/in Operators - {% if %} conditions now support and, or, and in boolean/membership operators with correct precedence and chaining. (#103)

    See docs/website/getting-started/installation.md.

Fixed

  • Pre-rendered DOM Whitespace Preservation - WebSocket mount no longer replaces innerHTML when content was pre-rendered via HTTP GET. Instead, data-dj-id attributes are stamped onto existing DOM elements, preserving whitespace in code blocks and syntax-highlighted content. (#99)

  • VDOM Keyed Diffing - Unkeyed children in keyed diffing contexts are now matched by relative position among unkeyed siblings, eliminating spurious insert+remove patch pairs when keyed children reorder. (#95, #97)

  • Event Handler Attributes Preserved - dj-* event handler attributes are no longer removed during VDOM patching. (#100)

  • Model List Serialization - Lists of Django Model instances are now properly serialized on GET requests. (#103)

  • Mount URL Path - WebSocket mount requests now use the actual page URL instead of a hardcoded path. (#95)

Changed

  • Dependencies - Upgraded html5ever 0.27→0.36, markup5ever_rcdom 0.3→0.36, vitest 2.x→4.x, actions/download-artifact 4→7. (#101, #102, #43)

Developer Experience

  • VDOM Debug Tracing - debug_vdom Django config is now bridged to Rust VDOM tracing. Mixed keyed/unkeyed children emit developer warnings. (#97)

0.2.0a2 - 2026-01-27

Changed

  • Internal: DRY Refactoring - Reduced ~275 lines of duplicate code across the codebase through helper function extraction. These are internal improvements that don't affect the public API. (#93, #94)
    • getComponentId() - DOM traversal for component ID lookup (client.js)
    • buildFormEventParams() - Form event parameter building (client.js)
    • send_error() - WebSocket error response helper (websocket.py)
    • _send_update() - WebSocket patch/HTML response helper (websocket.py)
    • _create_rust_instance() - Rust component instantiation (base.py)
    • _render_template_with_fallback() - Template rendering with Rust→Django fallback (base.py)
    • _make_metadata_decorator() - Decorator factory for metadata-only decorators (decorators.py)

0.2.0a1 - 2026-01-26

Changed

  • BREAKING: Event Binding Syntax - Standardized all event bindings to use dj- prefix instead of @ prefix. This affects all event attributes: @clickdj-click, @inputdj-input, @changedj-change, @submitdj-submit, @blurdj-blur, @focusdj-focus, @keydowndj-keydown, @keyupdj-keyup, @loading.*dj-loading.*. Benefits: namespaced attributes, no conflicts with Vue/Alpine, no CSS selector escaping required. (#68)

  • BREAKING: Component Consolidation - Removed legacy python/djust/component.py. Use djust.Component which now imports from components/base.py. (#89)

  • BREAKING: Method Rename - LiveComponent.get_context()get_context_data() for Django consistency. (#89)

  • BREAKING: Decorator Attributes Removed - Deprecated decorator attributes removed: _is_event_handler, _event_name, _debounce_seconds, _debounce_ms, _throttle_seconds, _throttle_ms. Use _djust_decorators dict instead. (#89)

  • BREAKING: Data Attributes Renamed - Standardized data attribute naming for consistency:

    • dj-liveview-rootdj-root
    • data-live-viewdj-view
    • data-live-lazydj-lazy
    • data-djdata-dj-id (#89)
  • BREAKING: WebSocket Message Types - Renamed message types for consistency:

    • connectedconnect
    • mountedmount
    • hotreload.messagehotreload (#89)

Added

  • LiveComponent Methods - Added missing methods to LiveComponent: _set_parent_callback(), send_parent(), unmount(). (#89)

  • Inline Template Support - LiveComponent now supports inline template attribute for template strings, in addition to template_name for file-based templates. (#89)

    See docs/website/guides/template-cheatsheet.md.

  • Form Components Export - ForeignKeySelect and ManyToManySelect are now exported from djust.components. (#89)

    See docs/website/guides/components.md.

Fixed

  • {% elif %} Tag Support: Template parser now correctly handles {% elif %} conditionals. Previously, elif branches fell through to the unknown tag handler and rendered all branches instead of just the matching one. (#80)

  • Template Include Fallback - Component render() methods now fall back to Django templates when Rust template engine fails (e.g., for {% include %} tags). (#89)

0.1.8 - 2026-01-25

Fixed

  • Nested Block Inheritance: Fixed template inheritance for nested blocks. When a child template overrides a block that is nested inside another block in the parent (e.g., content inside body), the override is now correctly applied. (#71)

0.1.7 - 2026-01-25

Added

  • Tag Handler Registry: Extensible system for custom Django template tags in Rust. Register Python callbacks for tags like {% url %} and {% static %} with ~100-500ns overhead per call. Built-in tags (if, for, block) remain zero-overhead native Rust. Includes ADR documenting architecture decisions. (#65)
  • Comparison Operators: Template conditions now support >, <, >=, <= operators in addition to == and !=. (#65)
  • Enhanced {% include %} Tag: Full support for with clause (pass variables) and only keyword (isolate context). (#65)
  • Performance Testing Infrastructure: Comprehensive benchmarking with Criterion (Rust) and pytest-benchmark (Python). New Makefile commands: make benchmark, make benchmark-quick, make benchmark-e2e. Enables tracking performance across releases and detecting regressions. (#69)
  • Inline Handler Arguments: Event handlers now support function-call syntax with arguments directly in the template attribute. Use dj-click="handler('arg')" instead of dj-click="handler" data-value="arg". Supports strings, numbers, booleans, null, and multiple arguments. (#67)

Fixed

  • Async Event Handlers: WebSocket consumer now properly supports async def event handlers. Previously only synchronous handlers worked correctly. (#63)

Performance

  • Dashboard render: ~37µs (27,000 renders/sec)
  • Tag handler overhead: ~100-500ns per call
  • Template variable substitution: ~970ns
  • 50-row data table: ~188µs

0.1.6 - 2026-01-24

Added

  • {% url %} Tag Support: Django's {% url %} template tag is now fully supported with automatic Python-side URL resolution. Supports named URLs, namespaced URLs, and positional/keyword arguments. (#55)

  • {% include %} Tag Support: Fixed template include functionality by passing template directories to the Rust engine. Included templates are now correctly resolved from configured template paths. (#55)

  • urlencode Filter: Added the urlencode filter for URL-safe encoding of strings. Supports encoding all characters or preserving safe characters. (#55)

  • Comparison Operators in {% if %} Tags: Added support for >, <, >=, <= comparison operators in conditional expressions. (#55)

  • Auto-serialization for Django Types: Context variables with Django types (datetime, date, time, Decimal, UUID, FieldFile) are now automatically serialized for Rust rendering. No manual JSON conversion required. (#55)

  • Lazy Hydration: LiveView elements can now defer WebSocket connections until they enter the viewport or receive user interaction. Use dj-lazy attribute with modes: viewport (default), click, hover, or idle. Reduces memory usage by 20-40% per page for below-fold content. (#54)

  • TurboNav Integration: LiveView now works seamlessly with Turbo-style client-side navigation. WebSocket connections are properly disconnected on navigation and reinitialized when returning to a page. (#54)

    See docs/guides/turbonav-integration.md.

Changed

  • AST Optimization: Template parser now merges adjacent Text nodes during AST optimization, reducing allocations and improving render time by 5-15%. Comment nodes are also removed during optimization as they produce no output. (#54)

Fixed

  • Nested Block Inheritance: Fixed template inheritance for nested blocks (e.g., docs_content inside content). Block overrides are now recursively applied to merged content, ensuring deeply nested blocks are correctly resolved. (#57)
  • Form Validation First-Click Issue: Added parse_html_continue() function to maintain ID counter continuity across parsing operations. Prevents ID collisions when inserting dynamically generated elements (like validation error messages) that caused first-click validation issues. (#54)
  • Whitespace Preservation: Whitespace is now preserved inside <pre>, <code>, <textarea>, <script>, and <style> elements during both Rust parsing and client-side DOM patching. (#54)

Security

  • pyo3 Upgrade: Upgraded pyo3 from 0.22 to 0.24 to address RUSTSEC-2025-0020 (buffer overflow vulnerability in PyString::from_object). (#55)

0.1.5 - 2026-01-23

Added

  • Context Processor Support: LiveView now automatically applies Django context processors configured in DjustTemplateBackend. Variables like GOOGLE_ANALYTICS_ID, user, messages, etc. are now available in LiveView templates without manual passing. (#26)

Fixed

  • VDOM Cache Key Path Awareness: Cache keys now include URL path and query string hash, preventing render corruption when navigating between views with different template structures (e.g., /emails/ vs /emails/?sender=1). (#24)

0.1.4 - 2026-01-22

Added

  • Initial public release
  • LiveView reactive server-side rendering
  • Rust-powered VDOM engine (10-100x faster than Django templates)
  • WebSocket support for real-time updates
  • 40+ UI components (Bootstrap 5 and Tailwind CSS)
  • State management decorators (@state, @computed, @debounce, @optimistic)
  • Form handling with real-time validation
  • Testing utilities (LiveViewTestClient, snapshot testing)

0.1.3 - 2026-01-22

Fixed

  • Bug fixes and stability improvements

Spotted a typo or want to improve this page? Edit on GitHub →