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'sRegroupNodeconsecutive-grouping semantics (input order preserved, never pre-sorted). Implemented as a built-in assign tag handler (RegroupTagHandler) plus a JSON-awareresolve_tag_arginrenderer.rsthat brings the assign-tag arg path to parity withCustomTag(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 onRegroupTagHandler): 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 intest_regroup_tag.pydrives the real Rust engine via bothrender_templateandRustLiveView.render_with_diffwith 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
localStorageUI-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-deadconfig.positionnow seeds the initial dock, andsetDock('bottom'|'left'|'right')is available at runtime. New cases intests/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 onlylogger.warning-ed when the resolved attr wasn't a bare identifier — it missed the shadow-to-an-identifier-value case (country → "usa"). Durable fix:AssignTagHandlernow carries aRESOLVE_ARG_POSITIONSclass attribute (set[int] | None;None= resolve all args, the unchanged default for any assign tag that doesn't opt in).register_assign_tag_handlerreads it once at registration, and one sharedresolve_assign_tag_argshelper — routed through all fourAssignTagdispatch sites inrenderer.rs(the #1646 parallel-path cure) — resolves ONLY the declared positions and passes the rest as literal tokens.RegroupTagHandlerdeclares{0}, so only the<expr>source is resolved (still JSON-encoded as before);by/<attr>/as/<var>stay literal, making the shadow impossible while dottedby author.teamcontinues to resolve as a per-item path. Removes the now-obsoletelogger.warning+_IDENTIFIER_REshadow heuristic fromregroup.py. Regression coverage intest_regroup_tag.py: two end-to-end shadow reproducers (a plaincountrykey and a dottedauthor.team, both viarender_template) plus a pin onRegroupTagHandler.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 onlyCustomTagandAssignTag(post-#2023) JSON-encoded structured (list/object) args;BlockCustomTagused 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-levelvalue_to_arg_string(previously duplicated inline inresolve_tag_argand theCustomTagarm) and routed all three branches through it, foldingBlockCustomTagonto the sameresolve_tag_arghelperAssignTagalready uses — the #1646 parallel-path cure (retire the collapse class, not fix 2-of-3).CustomTag/AssignTagbehavior is byte-for-byte preserved (filter-awareget_valueforCustomTag, scalars unchanged everywhere); the only behavior change isBlockCustomTagnow JSON-encodes list/object args. New end-to-end Rust test incrates/djust_templates/tests/test_block_custom_tag_arg_json_2042.rs(realBlockCustomTagdispatch through a registered Python block handler; single#[test]because parallelPython::attachacross cargo's default harness deadlocks) plus 5 Python-free unit tests inrenderer.rspinning the sharedvalue_to_arg_string/resolve_tag_argcontract; gate-off verified (reverting only theBlockCustomTagrouting turns the integration test red withgot "[List]"). -
A Django
Model/QuerySetstored on PUBLIC LiveView state (enable_state_snapshot) no longer silently comes back as a plaindictafter a back-navigation restore — it now fails loud and early instead._capture_snapshot_state(the client-signedstate_snapshot_signedmount-emission path inruntime.py) reusesdjust.serialization.DjangoJSONEncoder, which — unlike the plain encoder — does know how to serialize aModel(_serialize_model_safely). Soself.user = request.userinmount()silently succeeded the JSON round-trip and shipped as a lossy, disconnected field-value dict; on the next back-navigation restore,self.usercame back thatdict, not aUser, and a handler calling a model method on it (self.user.get_full_name()) broke with a confusing, origin-unclearAttributeErrorfar 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 shapestate_snapshot_signed's HMAC signing exists to prevent), so the fix instead rejects early: a newLiveView._reject_orm_value_in_state_persistenceguard, applied only when_capture_snapshot_state(strict=True)(the realruntime.pypersistence caller), raisesNonPersistableStateError(aTypeErrorsubclass) inDEBUGwith 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 onlystrict=Truecaller wraps snapshot emission in a broadexcept Exception(the #1788 "snapshot emission must never break mount" posture):runtime.pynow 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_stateis 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 forstate_before/state_after). 8 new tests intest_state_snapshot_orm_early_validation.py(DEBUG raise, production warn+skip, non-strict callers unaffected, rendering pipeline unaffected, and two real-pathdispatch_mountcases: 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 stubruntime.pytype-checks against) also declaresNonPersistableStateErrornow — mypy resolves module attributes against the.pyiwhen both it and the.pyexist, 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_argthen 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 %}-scopedblock.textwhose text contained=); a dotted value could likewise be re-resolved against the context._resolve_argnow 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 inTestResolveArgNoDoubleResolution,TestMarkdownHandlerResolvedSource, andTestRealPathLoopScopedMarkdown. -
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 aWeakMapkeyed 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 staletranslateY, overlapping the new view's real rows (a full page reload fixed it). Three client gaps closed in29-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 newreapStaleVirtualLists()discovers tracked containers via the never-removeddata-dj-virtual-shellmarker (theWeakMapisn'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 carriesdj-virtual; andabsorbLooseChildren()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 intests/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 themainpush-CI run at72d78601, satisfying the #1534 green-on-runner-first precondition, so it is promoted to a blocking step in thepython-testsjob — a failure now failspython-tests, which is in thetest-summaryAND-condition (#1713), so it gates the merge. The pre-pushpytesthook gainspython/djust/tests/too, matching CI (the explicittests/ python/tests/paths override pyproject'stestpaths, 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 ranpytest tests/ python/tests/— explicit paths that override pyproject'stestpaths— so a large suite (V008 checks, mount-chokepoint structural pins, restore tests) ran in neither CI nor the pre-push hook. As a result theTestSetattrChokepointCWE-915 mass-assignment guard had been RED onmainundetected (a sanctionedDynamicLiveViewfunction-view-decoratorsetattrsite drifted off the whitelist's pinned line numbers). The whitelist is re-verified and corrected, andpython/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_statelogged the four client-set theming cookies (djust_theme/_preset/_pack/_layout) to twologger.debugcalls unsanitized. The calls already used%sparameterization (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 throughsanitize_for_log(the CodeQL-recognized barrier indjust._log_utils, which strips CR/LF/control chars), matching the pattern already used acrossruntime.py,sse.py, and the theming gallery. Behavioral regression test inpython/djust/tests/test_theming_log_injection.pydrivesget_statewith 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_mountcreated 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, landingSetTextpatches on the wrong node (often a bare#textnode) —2/N patches failed→ anhtml_recoveryreload/flicker. The restore mount now setsview._force_full_html = True, so the first post-restore render emits a fullhtml_updateframe: 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 convergedmounted_from_restoreseam covers both restore mechanisms (session-saved-state- signed-snapshot HMAC) and all transports (WS
handle_mountis a thin shim todispatch_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 inpython/djust/tests/test_stale_baseline_restore_1977.py.
- signed-snapshot HMAC) and all transports (WS
- Converged the WebSocket and runtime async-callback dispatch onto one shared helper (#2020). #2016 fixed a #1646 parallel-path drift — an async
@backgroundhandler silently failed on the converged runtime path becauseViewRuntime._execute_async_taskrouted every callback throughsync_to_async(raisingTypeError: sync_to_async can only be applied to sync functions) while the WS consumer's_run_async_workawaited 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 onerun_async_callbackinmixins/async_work.pythat both transports now delegate to, so the sync/async handling can never diverge again. Newtest_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 owniscoroutinefunction(callback)branch, and the helper is the single definition in the package.
Added
- CI:
check-changelog-tagged-sectionspins already-shipped CHANGELOG sections against the newest release tag (#2028). A 3-way merge ofCHANGELOG.mdacross 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; neithercheck-changelog-test-countsnorcheck-adr-statuscatches it (both check the diff's own claims, not whether a shipped section changed at all). New pre-commit hookscripts/check-changelog-tagged-sections.pyfinds the newest release (top-most## [X.Y.Z]section whosevX.Y.Ztag 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 intests/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-scopeduserhas 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 RustFromPyObject__dict__bulk-dump (crates/djust_core/src/lib.rs), which filtered only_-prefixed keys, so it dumpedpasswordfor 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 rawdict/tuplerows 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 rawlist/tupleof models reached via a non-model intermediary —{% for x in presenter.items %}{{ x.password }}— whose elements reach the RustFromPyObjectVec<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_serializableand the same sensitive-method set (extracted to shared_SENSITIVE_MODEL_METHODS/_SENSITIVE_MODEL_METHOD_PREFIXESconstants 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 (vianormalize_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 inget_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 singleprotect_sidecarchokepoint (crates/djust_core/src/context.rs) that routes every just-materialized value — after bothgetattrand 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 throughnormalize_django_value(the denylist serializer) instead of the__dict__bulk-dump, so a raw model reaching aValuevia a list/tuple/dict container is floor-filtered too (vector 7). These are the two durable chokepoints — the getattr-walkprotect_sidecarand 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 thetemplate_auto_callkill-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 intest_template_auto_call_1985.py(TestSidecarSerializationFloorcovers 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 Rustprotect_sidecarchokepoint, or theFromPyObjectmodel routing makes the corresponding leak test RED). Field-type-based exclusion (always-dropBinaryField, encrypted-field types) is a follow-up hardening of both paths (#1987). -
TYPE-based serialization floor — always-drop
BinaryField+ encrypted-field types + a configurablesensitive_field_typeslist, 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-modeldjust_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 containingencrypted/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 newLIVEVIEW_CONFIG['sensitive_field_types'](a project-configurable list, empty by default; case-exact).FileField/ImageFieldare 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 inpython/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 theBinaryField-leak test RED). See SECURE_DEFAULTS Pattern 1. -
ViewRuntime.dispatch_mountgained 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 ofmount(); the payload is a server-signedTimestampSignerblob (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_mountnow ports the WS restore VERBATIM (websocket.py:2491-2587): the sameunsign_snapshot(blob, slug=view_path, sid=session_key)HMAC binding (a snapshot signed for view A / session S1 / older thanDJUST_STATE_SNAPSHOT_MAX_AGEdoes NOT restore), the same size cap (64 KB verified inner JSON), keyset cap (256 keys), dict-type cap, theDJUST_STATE_SNAPSHOT_ENABLEDoperator master-switch, and the_should_restore_snapshot(request)view-level veto. The session key for thesidbinding is sourced fromrequest.sessionand 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_snapshoton the mount frame,websocket.py:2754-2792) is also ported, opt-in only. Gatedenable_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 UNTOUCHED —handle_mountkeeps its own copy until the Phase 3.3b flip;RUNTIME_OWNED_VERBS/ WS routing /handle_mount_batchare unchanged (websocket.pyhas no diff). New suitepython/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 themount()default), each with a gate-off sibling (#1468). Gate-off verified: skipping the slug cap inunsign_snapshotmakes 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. -
ViewRuntimegained atransport.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 WShandle_eventalready re-checks per-event auth whenLIVEVIEW_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. NewTransport.recheck_event_auth(view) -> bool(default-True = no re-check) wired intoViewRuntime._dispatch_event_innerat the SAME point WS does — after the view-mounted check, BEFORE the actor branch and the handler.WSConsumerTransportreplays the WS bespoke logic verbatim (re-resolve the user from the scope session viachannels.auth.get_user, reflect ontoview.request.user, re-runcheck_view_auth_lightweight; on failurenavigateto the login url +close(4403)).SSESessionTransportre-checks against the LIVE event-POST request (session._event_request, stamped by the/event/+/message/endpoints just before dispatch — the current POSTer'srequest.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 onreauth_on_event+login_required/permission_required(default views pay nothing). #291 multiplexed-path care: the runtime clearsview_instanceUNCONDITIONALLY on aFalsereturn (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_batchis mount-only — but the close stays gateable if events are ever collected, matching the WS bespokeview_instance = Noneafter 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 suitepython/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_instancecleared; 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_mountbefore it could go live (#1885, ADR-022 Iter 0). The WebSockethandle_mountenforces the ADR-017 post-mount object-permission check (check_object_permission), butViewRuntime.dispatch_mountdid not — so a view whosehas_object_permission()returnsFalse(or whoseget_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_mounthas 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 sharedenforce_object_permissionchokepoint the other transports use (runtime.py, mirroringwebsocket.py:2554-2573), placed AFTERmount()(soget_object()can read URL-derived attrs) and BEFOREhandle_params+ render (so a denied object is never rendered or sent). Fail-closed; a no-op for views without a customget_object(behavior-preserving). Reproduce-first + gate-off (#1468) verified: a denied view mounts + leaks its rendered HTML before the fix, emits only apermission_deniederror frame after. New cases inTestDispatchMountObjectPermission(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.pyget_*/all/count/exists; serializer properties + explicitget_*), 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 theFromPyObjectstr()catch-all. The walk now implements Django's exactVariable._resolve_lookupsemantics at every segment (root, mid-path, final): no-argcall0();do_not_call_in_templates→ used as-is (Model classes,Choicesenums);alters_data→ never called, renders empty (the data-destruction guard —{{ user.delete }}cannot destroy data);TypeErrorfrom the call runs theinspect.signature(...).bind()probe (args-required → empty; internalTypeErrorpropagates); 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 aManager/QuerySet— in a LiveView that is a DB query per re-render (per WebSocket event), so precompute inget_context_data()on hot paths. Kill-switch:LIVEVIEW_CONFIG["template_auto_call"](defaultTrue);Falserestores the pre-ADR no-call walk. 16 doc-claim-verbatim tests inpython/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). Seedocs/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 thancopy.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 instate-primitives.md). The_snapshot_assignsfingerprint-truncation warnings and docstring already advised callingself.set_changed_keys({...}), but no such method existed (it wouldAttributeError). This adds the method toRustBridgeMixin(inherited byLiveView): it marks the given keys changed and sets_force_full_htmlto force the re-render the auto-skip would otherwise drop._changed_keysand_force_full_htmlare now in_FRAMEWORK_INTERNAL_ATTRS(excluded from the assigns snapshot), so assigningself._changed_keysdirectly 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_htmlskip-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_keysforces a full re-render. Verified on the productionViewRuntime.dispatch_eventpath (not justLiveViewTestClient, which bypasses the skip); gate-off (#1468): neutering the method turns the in-place-mutation render test RED. Seedocs/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/blockrender()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]]withignore_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 thatcasts Django's@keep_lazy-decorated (untyped →Any)mark_safetostr, absorbing the ~200-strongno-any-returncascade across every handler return without ignores; (b) inlinecast(...)/str(...)(runtime no-ops) at eachint()/float()/dict-key/attribute site of thekw.get(...) -> objectcascade, plus a handful of explicitvar: 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 (thecast/strcoercions are runtime no-ops; the onlystr()wraps that touch lookup keys were converted tocastto guarantee key identity).mypy python/djuststays GREEN (822 files) withdjust.components.rust_handlersstrict; gate-off-verified (#1468) — a wrong-typedintreturn injected into a handler (ModalHandler.render, declaredstr) 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. Seedocs/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]]withignore_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 underscoretemplate_tags/package, distinct from the Django-enginetemplatetags/package already flipped in M4d group 1); and the theme-gallery / component-storybook surface (theming/gallery/—viewsthe gallery/editor/diff + storybook DEBUG/staff-gated views,contextthe example-context + token-serialization builders,component_registry,urls,storybook). None of the three subpackages has atests/dir, so the ratchet completes each in one PR with no test sub-package to defer. Annotated with real types (params + returns — notAnycosmetics):HttpRequest/HttpResponseon the gallery views,list[dict[str, Any]]on the example builders,Callable[[Type[TagHandler]], Type[TagHandler]]on the@registerdecorator factory. Render output is byte-identical — the SafeString/HTML boundaries (format_htmlinflash,escapeinmarkdown,Template.renderinpwa,reverseinurl,staticinstatic,_client_config_htmlinclient_config, the dynamic component.render()incomponent_registry) returnAnyunder the lenient global config (Django + the cross-islandlive_tags._client_config_htmlare seen as untyped), so each is coerced withstr(...)at the boundary to satisfywarn_return_anyWITHOUT changing the returned (already-safe) HTML. One real type fix:scaffolding/generator.pylist_display_fieldsannotatedlist[str](was an un-annotated[]flaggedvar-annotated).mypy python/djuststays GREEN (822 files) with all 20 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typedintreturn injected intotemplate_tags/url.UrlTagHandler.render(declaredstr) turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed. Seedocs/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 thedjust.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-levelColorScale/ThemeTokens/ThemePreset/DesignSystem/ThemePackliterals, zero functions), the dependency-free re-export hub_base, the package__init__(pure re-exports), and the deprecated_legacymodule (theTheme/THEMESdataclass 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 untypeddictoverrides (__getitem__/__contains__/get/items/keys/values/__iter__/__len__) — annotated to match thedict[str, Theme]superclass signatures (the three view methods declare-> Anyfor the un-nameable concretedict_items/dict_keys/dict_valuesreturn 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/djuststays GREEN (822 files) withtheming/themes/*strict; gate-off-verified (#1468) two ways — a wrong-typedstrreturn on_legacy._DeprecatedThemesDict.__len__(declaredint) 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-leveltheming/modules are already strict (M4c part 3), but mypy'sdjust.theming.*glob matches only direct children, not the deeperdjust.theming.themes.Xsubmodules, so this subpackage needed its own override entry. Seedocs/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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans the loose top-level modules (appsDjustConfig,audit_astAST security-audit walker,audit_liveruntime auditor,bug_captureharness,checks_css_proposalproposed CSS system-checks,hookslifecycle registry,hot_view_replacementHVR engine,state_backend/template_backendback-compat re-export shims,template_filtershelpers,time_travelrecorder,utilsshared 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 — notAnycosmetics):db/decorators.notify_on_save.decoratetypedtype[models.Model]so_meta/labelresolve, with narrow# type: ignore[attr-defined]s on the dynamic_djust_notify_channel/_djust_notify_receiversintrospection attrs stashed on/deleted from the decorated model class; the signal receivers_on_save/_on_deleteannotated(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_backendnowcast(PresenceBackend, _registry.get())mirroring the already-strictstate_backends.registrypattern (the generic registry returnsAny);backends.redis.RedisPresenceBackend.countwraps the untypedzcountAny-return inint(...);db.notifications._import_psycopggained its-> tuple[Any, Any]return; anddb.notifications._dsn_from_url's URL-field loop variable was renamed (val→dsn_val) to stop colliding with the earlierstr-typedparse_qslloop var so the mixedstr | int | Nonefield tuple type-checks.mypy python/djuststays GREEN (822 files); gate-off-verified (#1468) — a wrong-typedintreturn inbackends.registry.get_presence_backend(declaredPresenceBackend) 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. Seedocs/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_componentswas 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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans the four template-tag modules (theme_components— the ~26 themed component tagstheme_button/theme_card/theme_alert/theme_input/theme_modal/theme_table/theme_nav/etc.;theme_pages— the auth/error/utility page-fragment tagstheme_login_page/theme_404_page/theme_maintenance_page/etc.;theme_tags— thetheme_head/theme_css/theme_switcher/theme_preset/theme_modeaccessors + the sharedbuild_theme_head_contextbuilder;theme_form_tags—theme_form/theme_form_errors/get_css_prefix) and thedjust_thememanagement 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 theirmark_safe/format_htmlreturn values are annotatedSafeString(the HTML-safe boundary) andcontext/request/formparams getContext/HttpRequest | None/BaseForm; output is byte-identical. The management command uses the establishedCommandParser/*args: Any, **options: Anyshape mirroringdjust_setup_css/djust_doctor. Four real type fixes to clean the islands (the kind strict-flips surface, ADR-023):theme_components.theme_progressannotatespercentage: float(themin(100, (int(value)/int(max))*100)reassignment is afloat; the= 0seed inferredint→[assignment]);theme_tags.theme_framework_overridesnarrows theformat_htmlresult through astrlocal at the unstubbed-django boundary ([no-any-return]); the three_css_prefix()helpers +theme_pages._csrf_token_valuewrap the untypedget_theme_config().get(...)/get_token(...)boundary instr(...); anddjust_theme.handle_marketplace_inforeads the required-positionalmp_theme_namevia subscript (not.get()) so it stays non-Optionalfor thethemes_dir / theme_namePath division +get_component_coverage(str, ...)call ([operator]/[arg-type]).mypy python/djuststays GREEN (822 files) with all 8 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typedintreturn intheme_pages._css_prefix(declaredstr) turns the gate RED ([return-value]), reverting restores GREEN. Behavior is byte-identical (annotations + thestr(...)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 optionaltheming/gallerysubpackage, which remains for a continuation batch. Seedocs/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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans the PWA layer (mixinsPWAMixin/OfflineMixin/SyncMixin,storageoffline backends +OfflineAction/SyncQueue,syncSyncManager/ConflictResolver,manifest,service_worker,utils), the optimization layer (fingerprintStateFingerprint/SectionCache/IncrementalStateSync,codegenserializer code-gen,query_optimizerselect/prefetch analysis,cacheSerializerCache,__init__), the multi-tenant layer (resolvers,managersTenantManager/TenantQuerySet,backendsredis/memory presence,middlewareContextVar tenant binding,mixinTenantMixin/TenantScopedMixin,audit,security,models,__init__— annotations only; tenant-isolation logic byte-identical), and the observability layer (viewslocalhost-gated endpoints,middlewarelocalhost gate,sql/timings/log_handler/tracebackscapture buffers,dry_runside-effect blocker,registry,urls,__init__). Annotated with real types (params + returns — notAnycosmetics), using the established mixin-collaborator pattern (# type: ignore[misc]on cooperativesuper().get_context_data()/dispatch()calls mirroringwizard.py/tenants;TYPE_CHECKING-onlypush_event/sync_queuestubs on the PWA mixins documenting the co-mixed-LiveViewcontract) and a narrow# type: ignore[import-untyped]ondry_run's lazyimport requests(a known-stub package mypy won't silence viaignore_missing_imports). Two real bugs fixed to clean the islands (the kind strict-flips surface, ADR-023):pwa.storage.OfflineAction.idwidened toUnion[str, int](callers forward an int model pk asobj_id; theSyncQueueaction-id params widened to match), andpwa.mixins.delete_offlinenow passes the requiredOfflineAction(data={})— omitting it raisedTypeErrorat runtime on every call (a guaranteed crash in an untested path).mypy python/djuststays GREEN (822 files) with all 31 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typedstrreturn inoptimization.fingerprint.StateFingerprint.version(declaredint) 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: thepwa/{templatetags,management}-style leaf packages do not exist for these four subpackages, so M4d(2) completes their non-test surface. Seedocs/adr/023-incremental-type-enforcement.md. -
Strict type enforcement on the
tutorials/,api/,template/, andstate_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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans the declarative guided-tour state machine (tutorials/— theTutorialStepdataclass +TutorialMixinasync tour loop, withif 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_functiondispatch views, the pluggableBaseAuth/SessionAuthcontract, 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_contentsloaders, theDjustTemplaterendering pipeline incl. the{% extends %}/{% block %}parser +{% url %}resolver, and theserialize_value→JSONValueserializer), and the LiveView state-persistence backends (state_backends/— theStateBackendABC, the in-memory + Redis backends, and the registry).api/andstate_backends/are security/correctness-relevant — annotations only, logic byte-identical: the_snapshot_assigns/_compute_changed_keysdiff, 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.pyiwire-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-JITDjangoJSONEncoder = None/_get_model_hash = Noneimport fallbacks intemplate/rendering.py; the transientNone-view health-check probe entry instate_backends/memory.py);cast(...)is used at the Django/zstd/Rust unstubbed-boundaryAnyleaks, andassert ... is not Nonenarrows already-guarded optionals (thenext_start.end()block-parser sites, the_get_compressor()compress path gated by_compression_enabled).mypy python/djuststays GREEN (822 files) with all 20 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typedintreturn instate_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 atests/subdir, so the ratchet completes each in a single PR (no test sub-package to defer). Seedocs/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]]withignore_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_introspecthelper; the full Django system-check family (configuration/security/templates/quality/components/integrations/accessibility+ the sharedutils); the auth layer (thecheck_view_auth/run_pre_mount_auth/enforce_object_permissionsecurity core, theLoginRequiredLiveViewMixin/PermissionRequiredLiveViewMixin, thesocial_auth_providerscontext processor, the signup/loginviews+forms, and thedjust_adminplugin + itsOAuthProvidersView/SocialAccountsViewLiveView pages); all five template-tag modules (live_tags— the big one with{% live_render %}/{% colocated_hook %}/{% dj_activity %}+ the lazy-thunk emitter, plusdjust_flash/djust_formsets/djust_pwa/djust_tutorials); and the loose modulescli,dev_server,deploy_cli,drafts,http_streaming,session_utils,push,middleware. Annotated with real types (params + returns — notAnycosmetics):SafeStringat themark_safe/format_htmlboundary;CheckMessagefor system-checkerrorslists + returns;argparse.Namespace/CommandParserfor the management commands;ast.*node types (ast.ClassDef/ast.Call/ast.expr/ast.Module) for the AST-based checks;AsyncIterator[bytes]for theChunkEmitterstreaming surface. The only narrow coded# type: ignores are at genuine dynamic edges: thedjust.checkssetattrre-export (_root.*— the patch-by-path contract from the #1822 monolith split), thedjust-adminoptional-dependency fallback class (no-redef/assignment), the optional_rustversionexport (not in the.pyi), the auth-mixin cooperativesuper().dispatch(provided by the combined View), and the Djangomodel._metaaccess (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/djuststays GREEN (822 files); gate-off-verified (#1468) — a wrong-typed return in a flipped module (templatetags/djust_flash.dj_flash→int) turns the gate RED ([no-any-return]), reverting restores GREEN. Full suite 8604 passed / 0 failed.requests(consumed bydeploy_cli) joinsyamlin the untyped-third-party override. Remaining for a continuation batch: themanagement/templatetags-adjacent long tail is already covered; thetenants/,backends/, andstate_backends/subpackages + the last few loose modules remain. Seedocs/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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans the registry layer (_registry_accessorsingleton +registrydiscovery wiring), theThemeManager+ThemeStatestate/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,mixinsThemeMixin,components,formsrenderer), the build/adapters/tooling (build_themes,shadcn,tailwind,inspector,checks,manifest,loaders,theme_packs,compat,contracts), theappsAppConfig,views,urls, and the leaf_config/_constants/_types/_builtin_presetsmodules. Annotated with real types (params + returns — notAnycosmetics), using thecast(str, mark_safe(html))boundary pattern for the theme-component renderers (django'ssafestringis unstubbed, somark_safereturnsAny;SafeStringitself resolves toAnywithout django-stubs, so astrcast is the honest no-Any-leak shape).mypy python/djuststays GREEN (822 files) with all 39 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typedintreturn inmanager.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 defensiveif self._theme_manager is None: returnguards in the fourThemeMixinevent handlers (no-ops on the real post-mount path, matching the existing_setup_theme_contextguard). Full suite 8604 passed / 0 failed; 1863 theming tests pass (incl. the previously-flakytest_theme_tags_rust_engine_1721, green via #1929's fixture). Remaining theming/ for a continuation batch: thetheming/{templatetags,management,gallery}subpackages (the templatetag modules are the heaviest —theme_components~51 errors — so they're a separate batch). Seedocs/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]]withignore_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 aTYPE_CHECKING-guarded import so the optionalmcpdep is never imported at module load,_ensure_django() -> bool,main() -> None, and the observability-tool returns); the binary-WebSocket-frame upload system (uploads/__init__— theUploadWriterbase +BufferedUploadWriter+UploadConfig+UploadManager,uploads/resumable— the resumable chunk protocol,uploads/storage— the in-memory + RedisUploadStateStoreimpls,uploads/views— theUploadStatusViewHTTP endpoint withHttpRequest/JsonResponseannotations); 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 atests/dir, so the ratchet completes in one PR with no test sub-package to defer. Annotated with real types (params + returns — notAnycosmetics); the only narrow coded edges are:# type: ignore[override]on the legacywrite_chunk(self, chunk)adapters (BufferedUploadWriter,GCSMultipartWriter,AzureBlockBlobWriter) — the dropped trailingchunk_indexdefault is an INTENTIONAL, runtime-dispatched part of theUploadWritercontract (_writer_accepts_chunk_indexintrospects the signature; documented on the base method), andcast(...)narrows at the untyped boundaries (json.loadsinuploads/storage,boto3.generate_presigned_urlins3_presigned,requests.Response.text+session.session_keyinmcp/server/uploads/views). Twomcp/serverobservability-toolparamsdicts inferred homogeneous-then-mutated-with-the-other-type were annotateddict[str, object]. Third-partyrequests(consumed bymcp/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/djuststays GREEN (822 files); gate-off-verified (#1468) — a wrong-typed return injected intouploads/storage.deleteturns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed (413 upload/mcp-related tests pass). Seedocs/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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans every non-testadmin_ext/module: theDjustAdminSite(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+ theadmin_login_requiredwrapper and the_VIEW_REGISTRYplumbing), theAdminFormMixin(FK/M2M option loading, date/time field detection, readonly handling, real-time field validation), the bulk-action progress widget +@admin_action_with_progressdecorator, theAdminTailwindAdapteradmin CSS-framework adapter, theregister/action/displaydecorators, theDjustAdminConfigAppConfig, the autodiscover package__init__, and the admin template-tag helpers (get_item/get_field/concat/admin_url). Excludesadmin_ext/tests/, which stays on the lenient global default. Annotated with real types (params + returns — notAnycosmetics):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: Anyannotation-only attrs onAdminBaseMixin+AdminFormMixindocumenting the co-mixed-LiveViewcontract, plus a# type: ignore[misc]on the cooperativesuper().as_view()mirroringwizard.py); decorator function-attribute stamping (wrapper.short_description = ...) carries narrow# type: ignore[attr-defined]at the genuine dynamic edge.mypy python/djuststays 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_adapters→int) 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. Seedocs/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 entiremixins/subpackage — the LiveView mixin layer that composes the publicLiveViewclass — from the lenient mypy default to strict islands ([[tool.mypy.overrides]]withignore_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_work—start_async/defer/assign_async,waiters—wait_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 (context—get_context_data/_apply_context_processors/_deep_serialize_dict,jit—_jit_serialize_queryset/_jit_serialize_model/_get_template_content), the HTTPrequestmixin (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 largetemplaterendering mixin (render/render_full_template/render_with_diff/arender_chunks+ the HTML extraction/stripping helpers). Annotated with real types (params + returns — notAnycosmetics), using the establishedif TYPE_CHECKING:host-attribute-declaration pattern (mirroringstreaming.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-RustRustLiveView = None/extract_template_variables = Noneimport fallbacks; theevent_handlerdirect-file-import fallback shim; the dynamiccomponent_id/_auto_idattribute sets on theComponent | LiveComponentunion).rust_bridge/jitchange-detection is byte-identical — annotations are runtime no-ops; the_sync_state_to_rustchange-detection, the_framework_attrs-class filter conventions, and all id()/value comparison logic are UNTOUCHED (no comparison or filter expression was altered).mypy python/djuststays GREEN (822 files); gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict mixin (template.get_template→int) turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed. Themixins/ratchet completes in a single PR (nomixins/tests/sub-package exists to defer). Seedocs/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]]withignore_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 — everyNode.render(self, args/content, context) -> SafeString,do_*(parser, token) -> template.Node, and@register.simple_tag/inclusion_tagfunction, with inclusion_tags correctly typed-> dict[str, Any]since they return a context dict, not HTML), the per-widgetmixins/data_table(theDataTableMixin— its ~21on_table_*event handlers,handle_*override hooks,get_*/_apply_*pipeline, and the safe-arithmetic expression parser), the gallery LiveView surface (gallery/live_views—GalleryCategoryMixin+ 9 category views, with thetemplate_nameLiskov conflict resolved by aTYPE_CHECKING-onlyLiveViewbase alias;views,examples,registry,context_processors, and thecomponent_gallerymanagement command), the ~24 remainingcomponents/components/*widgets with untyped private-helper params (_render_node/_squarify/_compute_diff/_eval_expression/etc.), thelayout/tabs/data/pagination/ttyd/terminalleaves, and theui/*_simplestateless widgets +ui/dropdown(the over-narrow nav-item dict widened to the honestAnycontract per #1108; the optional-Rust import shims —from djust._rust import RustX/RustX = Nonefallbacks 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 themark_safe(...) -> SafeStringboundary pattern (noAnyleak).rust_handlersis deliberately left LENIENT — it is a genuinely-dynamic Rust-bridge registry whose 193 handlers parse untyped Rust-engine arg lists intodict[str, object](thekw.get() -> objectcascade), so strict typing surfaces 344 errors (203no-any-return+ 91call-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/djuststays GREEN (823 files) with all 68 strict andrust_handlerslenient; gate-off-verified (#1468) — a wrong-typed return inmixins/data_table([return-value]) and a dropped annotation intemplatetags/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 documentedrust_handlersexception remains lenient within components/). Seedocs/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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans the fullcomponents/components/widget catalog (146 modules — alert, badge, card, spinner, kanban-adjacent leaves, charts, etc.), theui/stateless widgets (8 — spinner, modal, alert, progress, badge, button, card, list_group), thedata//forms//layout//gallery//management//ttyd/leaf packages, the descriptor-based components (descriptors/*— the DEP-002Accordion/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 — notAnycosmetics): typed*_instancesclass vars (Optional[Dict[str, XState]]),instance_id: str/component_id: str/is_open: boolparams,get_*_ctx(...) -> Dict[str, Any]accessors, andrender() -> SafeString(mirroring themarkdown.pyisland —mark_safe(...)returnsAnyunder django's unstubbedsafestring, soSafeStringis the correct str-compatible annotation that cleanly absorbs theAnywithout 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/djuststays 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-errormark_safe/kw.get()-objecticeberg, a separate decision), the per-widgetmixins/data_table, the big templatetag modules (templatetags/djust_components/_advanced/_forms/_charts), thegallery/live_views/views/examplesLiveViews, the ~24components/components/*widgets with untyped private-helper params (_render_node/_squarify/_compute_diff/etc.), and the union-typedui/*_simplewidgets +ui/navbar_simple/modal/dropdown(over-narrow dict inference + the declared-but-unbuiltRustNavBar/Rust*fallback imports). Seedocs/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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any):components.__init__,components.apps,components.registry(theLiveComponentname registry),components.assigns,components.dependencies(theDependencyManagerCSS/JS asset registry),components.function_component(the@componentdecorator +{% 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(sharedformat_cell/interpolate_color/interpolate_color_gradient+CURRENCY_SYMBOLS),components.mixins.base(the per-component interactive mixin base —ComponentMixin+ theTypedStatedict subclass),components.templatetags._registry(the sharedtemplate.Library+ the security-sensitivesafe_urlscheme-validator +_resolve/_parse_kv_args), andcomponents.templatetags._dev_tools(the Terminal/MarkdownEditor/JsonViewer/LogViewer/FileTree dev-tool template tags). Annotated with real types (params + returns — notAnycosmetics); the only narrow coded# type: ignore[attr-defined]are at genuine dynamic edges (the@componentdecorator stamping_djust_*metadata onto a plainCallable; the per-invocation_slots/_childrenattached to aLiveComponentinstance for template render). Themark_safe-returns-Anyboundary is handled with typed-local narrowing (a small_safe(html: str) -> strwrapper in_dev_tools,str-typed locals elsewhere) — noAnyleak.mypy python/djuststays 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-> stris added — themark_safe/kw.get()-objecticeberg), the per-widgetmixins.data_table, and the big templatetag modules (djust_components/_advanced/_forms/_charts), plus the UI catalog (ui/,data/,forms/,gallery/, charts UI). Seedocs/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]]withignore_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(thelive_sessionURLconf walk + auth-filtered route-map emit),formsets,simple_live_view,testing(the publicLiveViewTestClient+SnapshotTestMixin+LiveViewSmokeTestfuzz/smoke harness),react,rust_components,frameworks(the CSS framework adapters),js(theJScommand-chain builder),wizard(WizardMixin),performance,profiler, andpresence(PresenceMixin+LiveCursorMixin). Annotated with real types (params + returns — notAnycosmetics); the only# type: ignore[misc]are at genuine mixinsuper()-delegation edges (wizardmount/get_context_data, which the LiveView MRO supplies at runtime).mypy python/djuststays 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). Seedocs/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/ViewRuntimedispatch spine (every mount + event flows through them) are now mypy strict islands ([[tool.mypy.overrides]]withignore_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 — notAnycosmetics):runtime.py(42 strict errors —ViewRuntimedispatch helpers,_build_request/_check_auth/_extract_*, the actor-mount path,_tenant_context),websocket.py(73 —LiveViewConsumerlifecycleconnect/disconnect/receive, thehandle_*verb handlers, the Channels event handlersserver_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 — theDjustSSE*Viewget/postHTTP handlers, the owner-binding helpers, the SSE event-stream async generator),streaming.py(6 —StreamingMixin, withTYPE_CHECKINGhost-class attribute declarations), andwebsocket_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-binaryreceive()text frame).mypy python/djuststays 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). Seedocs/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 thatpy.typedexposes to downstream consumers are now mypy strict islands ([[tool.mypy.overrides]]withignore_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_viewdecorator + private-state helpers) and its PEP 561 stublive_view.pyi;components/base.py(theComponent+LiveComponentpublic bases — descriptor protocol, render waterfall, event-handler factory);decorators.py(@event_handler,@action,@server_function,@reactive,@state,@computed,@optimistic,@background+ their nested wrappers/descriptors); andforms.py(FormMixin+LiveViewForm).mypy python/djuststays 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). Seedocs/adr/023-incremental-type-enforcement.md. -
Enforced incremental type-checking — a mypy merge gate + strict islands + the
_rust.pyiboundary (ADR-023). djust shipspy.typed(PEP 561 — downstream consumers type-check against djust's hints), andpyproject.tomldeclared a strict[tool.mypy]config — but mypy was invoked nowhere (CI / Makefile / pre-commit), so the strict config was dead andmypy python/djustreported 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]]withignore_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), plusrate_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-errormypystep in thepython-testsCI job (a new MERGE GATE — #1236 governance — wired into thetest-summaryAND-condition; ships gating because it is green by construction, per #1534), amake typechecktarget (inmake check), and a scoped pre-commit hook onpython/djust/**.py{,i}changes. The_rust.pyistub'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) sincepy.typedexposes it. Seedocs/adr/023-incremental-type-enforcement.md. -
Mount-spine parity nets + 6 real-
WebsocketCommunicatorflip 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.pycharacterizes the six mount behaviors the flip must preserve, driving each against the CURRENT bespokehandle_mountover a real channelsWebsocketCommunicator(each passes now + must stay green through the flip = the parity proof, #1466/#1780/#1468): actor MOUNT (ause_actorsview renders an actor-backed mount frame, NOT the SSE refusal — Finding D),sticky_hold-before-mount-frame ORDERING vialive_redirect_mount(Finding B), Channelsgroup_addserver-push reachability (a broadcast to the mounted view's group reaches the session), periodic tick started at mount (asource="tick"frame arrives with no client event),optimistic_rules+upload_configson 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 nullsself.view_instancebefore re-mounting, and a naive flip that forgets to also resetruntime.view_instancewould silently no-op the re-mount sincedispatch_mountearly-returns whenview_instance is not None). Each asserts intermediate state + has a gate-off/contrast sibling.python/djust/tests/test_transport_behavioral_parity.pygrows 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_pendingsource pin) and extends_WS_ONLY_MARKERSwith 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"}) andhandle_mount/handle_mount_batchare 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 (viaengine.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 viaLIVEVIEW_CONFIG['filter_bridge_warm'] = False. New cases intest_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-023TYPE_CHECKINGstub idiom). The strict-mypy ratchet addedif 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'spy/ineffectual-statementflags 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'squery-filtersso it doesn't recur as the type-checking blocks grow. Also converted acast("Any", …)string forward-ref to a directcast(Any, …)incomponents/rust_handlers.pyso CodeQL sees theAnyimport 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.gotoflake on the BLOCKING browser-smoke gate (#1943). The blocking browser-smoke gate (#1869) intermittently red-barred unrelated PRs withPage.goto: Timeout 30000ms exceedednavigating to/demos/browser-smoke/: on a cold-cache run that compiles thedjust_componentsRust crate from scratch, the demo uvicorn server wasn't ready within the fixed 30spage.gotodeadline (it cleared on a warm-cache re-run — not a real break). The.github/actions/djust-playwright-serverreadiness 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/, usingcurl -fsSso a half-initialized app's 5xx does NOT count as ready — this warms the route (first-hit lazy import/compilation) DURING the bounded loop sopage.gotolands on an already-warm route instead of racing its own deadline.tests/playwright/test_browser_smoke.py'spage.gotoalso 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.logdump) 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-blockingplaywright-testsleg into its OWN dedicatedbrowser-smokeCI job (nocontinue-on-error) and wired into thetest-summaryaggregate gate's AND-condition, so a re-introduced runtime break of either class now red-bars the PR (mirrors thedemo-checksblocking-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-blockingplaywright-testsleg — 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 viawindow.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"inRUNTIME_OWNED_VERBS, soreceive()routes every WS mount frame through the singledispatch_message→dispatch_mountchokepoint, and the ~870-line bespokehandle_mountbody is DELETED — reduced to a THIN SHIM overdispatch_mount(mirroring the event flip #1907 andhandle_url_change). Phases 3.0-3.3a had already growndispatch_mountinto a functional superset (F22 view resolver,run_pre_mount_authpre-mount auth+tenant via_check_auth,on_mounthooks, session + signed-snapshot state restore, post-mount object-permission,handle_params, actor mount, no-arm mount wire version, thesticky_holdpre-mount frame, the auth verdict→close finalize, the 2-queue mount-time drain) viaWSConsumerTransporthooks. This PR is the atomic flip with the three load-bearing findings wired: (A) idempotency — the shim, the_dispatch_runtime_ownedmount arm,disconnect, and thelive_redirectteardown all nullruntime.view_instanceBEFORE dispatch, so a reconnect /live_redirectre-mount is never silently no-op'd bydispatch_mount'sif view_instance is not Noneearly-return (the #560-class landmine); (B) ownership inverts — mount CREATES the view (runtime→consumer), so the shim reads backself.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_notifygroup_add, the periodictick_intervaltask, theuse_actorsflag, the real-scope_websocket_path/_websocket_query_stringstamps, the_sticky_auto_reattachedreset) is folded into the now-LIVE WSon_view_mountedtransport hook — madeasynctoawaitgroup_add— Finding B residual; (C) mount wire version via thenext_mount_versionhook (the no-arm consumer counter). The object-perm denial now closes the socket viafinalize_mount_auth(Finding E — the bespoke unconditionalclose(4403)had no runtime equivalent). Amount_batchbug the flip surfaced is also fixed:ViewRuntime._instantiate_viewfire-and-forgot its error frame viaasyncio.ensure_future, leaking a FAILED view's error into the NEXT survivor's collector (flipping a survivor tofailed[]); it now stashes the frame anddispatch_mountawait-sends it inside the correct_mount_onewindow.handle_mount_batch/_mount_onestay WS-only (the collector contract is unchanged;finalize_mount_authstill gates the redirect-verdict close onnot _mounting_in_batchper #291/#1780). Boundary pins updated to the post-flip reality: theRUNTIME_OWNED_VERBScontract, the Concern-4 mount-orchestration count-canary (run_pre_mount_auth/ object-perm /validated_host_from_scopeconverged ontoruntime.py),_WS_ONLY_MARKERS(group_add/channel_layer/tick_intervalmoved to the runtime hook), and thehandle_mountsource-grep pins (snapshot sign/unsign, skip-html,_ensure_tenant-before-restore,has_ids, mount-url validation, next-version) moved todispatch_mount; the fake consumers intest_sw_advanced.py/test_sw_advanced_flow.pygained a permissive_rate_limiterso they drive the runtime path. Gate-off-verified (#1468): neutering the Finding-A null makes thelive_redirectre-mount net (test_ws_mount_flip_parity_1911.py::TestLiveRedirectRemountIdempotency) RED; neutering theon_view_mountedfold makes thegroup_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 WShandle_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_VERBSis UNCHANGED ({"url_change", "event"}),handle_mount/handle_mount_batchare UNTOUCHED (websocket.pyhas no diff) — but the dormant hooks are now called bydispatch_mountat their WS-faithful positions (read offhandle_mount): (1)on_view_instantiated(view)right after instantiation (WS stamps_ws_consumer/_push_events_flush_callback/ observabilityregister_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 WSuse_actorsview now RENDERS through the actor system at the render step (verbatimhandle_mountordering — after auth +mount()+handle_params, html sent without strip/extract,websocket.py:2691-2706); SSE keeps refusing (uses_actors_for_mount→ False, so the structureduse_actors is not supported over SSEenvelope 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 (WSconsumer._next_version()— establishes the baseline, does NOT armrequest_htmlrecovery so_recovery_htmlstaysNone; SSE returns the raw Rustrender_with_diff()version, IMPLEMENTED here — the 3.2 SSE placeholder raised). The signature is widened to(html, rust_version=1)mirroringnext_client_versionso 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 ARMINGnext_client_versionthe event path uses. (4)on_mount_render_ready(view, html)(Finding B residual) runs after render, before the mount frame (WS sticky preservation + thesticky_holdframe emitted BEFORE the mount frame; SSE returnshtmlunchanged). (5)finalize_mount_auth(view, verdict)(Finding E) on the three auth-block verdicts (_check_authpermission_denied + redirect;dispatch_mountrun_on_mount_hooksredirect) — the runtime already sent the verdict frame + clearedview_instance, so the hook adds ONLY the transport-levelclose(4403)(WS unconditional for permission-denial, gated onnot _mounting_in_batchfor 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_mountis now a clean superset (Findings A/B prep) — the idempotency guard +view_instanceownership are untouched; the residual delta for the 3.3b flip is the routing flip + the A/B shim (theruntime.view_instancereset + read-back) only. New cases inTestRuntimeBasicMountParity,TestRuntimeActorMountParity,TestRuntimeNoArmVersionWiring,TestRuntimeAuthBlockFinalize,TestRuntimeStateRestoreParity(python/djust/tests/test_runtime_mount_parity_1917.py) — THE key 3.3a gate: drivesdispatch_mountover a REALWSConsumerTransport(direct-call shim, NOT viaRUNTIME_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, thenext_mount_versionwiring off → the wrong version is stamped. The Phase-3.2 DORMANT pins inpython/djust/tests/test_transport_mount_hooks_1915.pyare INVERTED to load-bearing WIRED pins (each hook is now referenced indispatch_mount/ the auth helper; SSEnext_mount_versionreturnsrust_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 theTransportprotocol (behavior-preserving no-op / refuse defaults),WSConsumerTransport(the real WS impl, each encapsulating the verbatim bespokehandle_mountlogic for its cited site), andSSESessionTransport(no-op / raw / refuse), addressing ADR-022 Iter 3 Findings B/C/D/E: (1)on_view_instantiated(view)— WS stampsview._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 (thedispatch_mountrefusal stays). (3)next_mount_version(html)— WS returnsconsumer._next_version(), the NO-ARM counterhandle_mountuses (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 fromnext_client_version, which arms for render-SEND frames), so_recovery_htmlstaysNoneafter 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_idssurvivor scan +_register_childre-registration) + thesticky_holdframe emitted BEFORE the mount frame (websocket.py:2080-2082/2836-2903), returninghtmlunchanged; SSE: returnshtmlunchanged (Finding B residual). (5)finalize_mount_auth(view, verdict)— WS: the transport-level socketclose(4403)the bespoke auth-finalization performs (websocket.py:2337-2401), GATED onnot consumer._mounting_in_batchfor 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_mountdoes NOT call any of these yet (Phase 3.3a wires them in) and the WS bespokehandle_mount/handle_mount_batchkeep doing all of this inline (untouched until the Phase 3.3b flip);RUNTIME_OWNED_VERBS/ WS routing are UNTOUCHED;websocket.pyhas no production diff. New cases inpython/djust/tests/test_transport_mount_hooks_1915.py(Test...MockTransport unit tests per hook + real-WebsocketCommunicatortests exercising the WS impls in isolation against a genuinely-mounted consumer —uses_actors_for_mountTrue for ause_actorsview,next_mount_versionreturns the consumer counter WITHOUT arming recovery,finalize_mount_authdoes NOT close when_mounting_in_batch=True) + DORMANT pins (dispatch_mountdoesn't reference the hooks, still stamps the raw Rust version + still refuses actor mounts;handle_mountstill does the work inline). All gate-off-verified (#1468): arming recovery innext_mount_versionreds the 3 no-arm tests, removing thenot _mounting_in_batchgate reds the in-batch tests, no-op'ingon_view_instantiatedreds the stamp test. The anti-drift_WS_ONLY_MARKERSpin (test_transport_behavioral_parity.py) dropscreate_session_actor/_find_sticky_slot_ids/register_view(no longer WS-only — the dormant WS hooks now reference them inruntime.py), mirroring the Phase-3.1state_snapshot_signedmove. -
ViewRuntime.dispatch_mountgrew the transport-agnostic mount STATE-RESTORE +on_mounthooks WebSockethandle_mounthas, 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 onenable_state_snapshot(#1552) so default views are unaffected: (1)run_on_mount_hooks(websocket.py:2383-2401) runs the registeredon_mounthooks after the pre-mount auth sequence + beforemount(); a hook that returns a redirect URL emits anavigateframe, clears the unmounted view, and aborts — transport-agnostically (no socketclose(); that belongs to the Phase 3.2/3.3afinalize_mount_authhook, 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 ofmount(). (3) Thehas_prerendered→skip_html_for_resumeresume optimization Phase 3.0 wired (but left dormant) now ACTIVATES: a restore (session or signed-snapshot) sets the new_mounted_from_restoreframework flag, so a resuming client that already holds the DOM skips the redundant mount-HTML swap (theversionstill flows so patches stay in sync)._mounted_from_restoreis initialized inLiveView.__init__BEFORE the_framework_attrssnapshot (#1393) so it is reset on reconnect and never persisted. Blast radius: SSE mount (which usesdispatch_mount) + the runtime;websocket.pyhas no diff,RUNTIME_OWNED_VERBS/handle_mount/handle_mount_batchare unchanged. The anti-drift_WS_ONLY_MARKERSpin dropsstate_snapshot_signed(no longer WS-only — now on the runtime too) and thelive_view.pysetattr-whitelist line numbers shift +11. New cases inpython/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); anon_mountredirect emits anavigateframe + aborts (RED when the redirect handling is gated off). -
ViewRuntime.dispatch_mountgrew the transport-agnostic mount behaviors WebSockethandle_mounthas, 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 ofhandle_mountover 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_kwargsstash (#1895,websocket.py:2596, placed aftermount()+ object-perm, beforehandle_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_baselinepost-mount (websocket.py:2598-2603); (3)has_prerendered→skip_html_for_resumemachinery (websocket.py:2804-2816), dormant until Phase 3.1 wires session-restore (the_mounted_from_restoreflag defaultsFalse, so HTML is always sent today); (4)optimistic_rules(DEP-002) +upload_configson the mount frame (websocket.py:2823-2834, via a new runtime_extract_optimistic_rulesmirror); (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_pendingthe 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 intest_handle_mount_drains_queues.py. Blast radius: SSE mount (which usesdispatch_mount) + the runtime;websocket.pyhas no diff andRUNTIME_OWNED_VERBSis unchanged. Every grow has a gate-off witness intest_transport_behavioral_parity.py(7/7 verified RED). New cases inTestMountStashAndBaselines,TestMountAsyncAndPushDrain,TestMountFrameOptimisticAndUpload,TestMountFrameWireVersion. -
THE FLIP: every WebSocket event now routes through
ViewRuntime.dispatch_event— the bespoke_handle_event_inneris 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 toRUNTIME_OWNED_VERBS(now{"url_change", "event"}), soreceive()routes every WS event through the singleViewRuntime.dispatch_messagechokepoint;handle_eventbecomes a thin shim overruntime.dispatch_event(mirroringhandle_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 newTransporthooks (SSE no-op):on_render_emittedcarries the production-visible DJE-053 warning (#1079 — it MUST survive, and does) plus the_emit_full_html_updatesignal on the no-patch render branch, andon_handler_timingcarries therecord_handler_timingpercentile telemetry;cache_request_idwas 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_navigationis nowawait-ed (was fire-and-forget) and (2) the skip-render branch now calls_flush_all_pending, so alive_redirect()/ navigation command queued by a state-unchanging handler still emits itsnavigationframe within the event turn (WS parity); and (3) the runtime's_dispatch_event_rendernow records a time-travel snapshot witherror="permission_denied"/"validation_failed"on the security-rejected + validation-rejected early-return paths (record_event_startmoved BEFORE the security check) — the bespoke_handle_event_innerrecorded these for the debug panel, and the first flip pass dropped them for non-actor views (caught bytests/integration/test_time_travel_flow.py::test_permission_denied_view_handler_records_with_error). Boundary pins updated (RUNTIME_OWNED_VERBScontract, 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. NewTestResidualFoldObservability(DJE-053 +record_handler_timingsurvival, with reason/version gate-off siblings) and aWebsocketCommunicatorregression forstart_async/@backgroundstreaming itssource="async"result over the runtime async path. Gate-off (#1468): removing"event"fromRUNTIME_OWNED_VERBSmakes all 11test_ws_event_flip_parity_1896behaviors fail withUnknown message type: event(the bespokeelifis 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. -
ViewRuntimeasync-result frames now carrysource="async", reconciling them with the WebSocket_run_async_workframes; and the deaduse_binaryframing 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) asyncsource="async"reconcile —ViewRuntime._render_async_result(thestart_async/@backgroundcompletion render shared by the success + error paths) emittedpatch/html_updateframes with NOsourcetag, while the WS_run_async_worktags all four of its framessource="async"(websocket.py:1166/1186/1223/1238). The client usessourceto distinguish an out-of-band background-completion update from the in-turnsource="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 stampsource="async". LIVE for SSE +url_changeasync work (both use the runtime async dispatcher today); WS picks it up post-flip (Phase 2.3b). (2) binary-framing confirm —consumer.use_binaryis dead: initialized toFalseatwebsocket.py:580('MessagePack support TODO') and never setTrueanywhere in the package; the only honoring site is_send_update's binary branch (websocket.py:1391), whichWSConsumerTransport.senddoes NOT traverse (it callsconsumer.send_json, always JSON). DESCOPED (no new binary path invented) + PINNED so a future enable is a deliberate, tested change: a guard test assertsWSConsumerTransport.sendemits JSON viasend_json(matching live WS), plus a source-grep pin that no production module assignsuse_binary = True. No change toRUNTIME_OWNED_VERBS/ WS routing; WS_handle_event_inner's async/binary paths stay on the bespoke handler until 2.3b;websocket.pyhas no diff. New cases inTestAsyncSourceReconcile/TestBinaryFramingConfirm(python/djust/tests/test_runtime_reauth_async_1905.py): real-SSE end-to-end (astart_asynccompletion frame carriessource="async") + unit (both branches tagged) + the JSON-emit + source-grep pins, with a gate-off witness (#1468) — removing thesource="async"tag makes the SSE end-to-end + unit assertions RED.test_async_integration+test_sse_runtime_convergence_1887stay green. -
ViewRuntimegained 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 lackeddj_activitydeferral 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_innerdoes (websocket.py:3254-3273gate +4290-4294flush). Two parts: (1) Gate —ViewRuntime._dispatch_event_render(after embedded-child routing, before security validation) replicates the WS gate VERBATIM, reusing the SAME transport-agnosticActivityMixinview 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-blindActivityMixin._flush_deferred_activity_eventsas the_dispatch_single_eventprovider, somixins/activity.pyis UNCHANGED (the flush already accepts any object exposing that method). The newViewRuntime._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-enteringevent_context— it already runs inside the borrowed context (which on WS holds the consumer_render_lock; re-acquiring the non-reentrantasyncio.Lockwould deadlock, thewebsocket.py:1467contract). A denied queued event is re-validated and dropped (WS flush per-event parity). Live behavior: this goes LIVE for SSE events — they route throughdispatch_eventsince Iter 1 (#1887), so SSE events now respectdj_activitydeferral (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_innergate/flush stays until Phase 2.3b;RUNTIME_OWNED_VERBS/ WS routing are UNTOUCHED;websocket.pyhas no diff. New suitepython/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 WSdj_activitybehavior (tests/unit/test_activity.py), the #1896 parity net (bespoke path, unchanged), andtest_sse_runtime_convergence_1887stay green. -
ViewRuntimegained an actor-event transport hook (transport.uses_actors()+transport.dispatch_actor_event()) so ause_actorsview'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_eventhad NO actor branch, while theuse_actorsguard lived ONLY indispatch_mount(which refuses SSE outright). A WS view mounts in actor mode (use_actors=True+ a createdactor_handle); once Phase 2.3b routes WS events through the runtime, such a view's events would have hitdispatch_eventwith no actor branch and silently run the handler IN-PROCESS via the normal render path, desyncing the actor's server-side diff baseline. Two newTransporthooks close the gap: (1)uses_actors(view)—WSConsumerTransportreturnsconsumer.use_actors and consumer.actor_handle is not None(the exact precondition of the bespoke WS actor block,websocket.py:3282),SSESessionTransportreturnsFalse(SSE has no bidirectional actor channel anddispatch_mountrefusesuse_actorsmounts,runtime.py:602); (2)dispatch_actor_event(view, event_name, params, *, event_ref, cache_request_id)—WSConsumerTransportruns the bespoke WS actor block (websocket.py:3282-3379) VERBATIM against the consumer (time-travel record/push in afinally, the shared_validate_event_security+validate_handler_paramschecks,actor_handle.event(), patch/HTML framing stamped with the consumer-owned wire versionconsumer._next_version()— the actor's internalresult['version']is IGNORED for the wire, #1788 — error handling, and the v0.7.0 deferred-activity flush),SSESessionTransportraisesNotImplementedError(never called —uses_actorsisFalse). Wired into_dispatch_event_innerBEFOREevent_context(the actor block holds no render lock, matching WS), gated onuses_actors(view)AND the event NOT being routed to a sticky child — the WSnot is_embedded_child_targetmutual exclusion (websocket.py:3280-3282); per #1467 acomponent_idevent does NOT reassign the target view and the WS actor block has no component handling, so acomponent_idevent on ause_actorsview goes through the actor (parity), and only aview_idresolving to a DIFFERENT child excludes it (_event_routes_to_sticky_childpeeks atview_idWITHOUT consuming it, so the non-actor sticky-child routing still pops it). Zero live-behavior change:uses_actorsisFalsefor 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_inneractor block are UNTOUCHED (they stay until 2.3b);websocket.pyhas no diff. New direct-runtime suitepython/djust/tests/test_transport_actor_event_1901.py(12 cases) builds aWSConsumerTransportover a fake consumer withuse_actors=True+ a fakeactor_handleand assertsdispatch_eventroutes todispatch_actor_event(the actor's.event()is called + the framed result is sent via_send_updatewith the consumer-owned wire version, NOT the in-process handler),uses_actorsFalse for SSE + a WS consumer withoutactor_handle, aview_id-routed event skips the actor while aview_id-equals-top event still routes to it, and the SSEdispatch_actor_eventraises; gate-off verified (#1468) — forcinguses_actorsto always returnFalsemakes 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 #1899event_contextsuite stay green. -
ViewRuntimenow BORROWS the consumer's render-lock + origin-channel + observability scope for each event via a newtransport.event_context()hook, and the dead runtime-local_render_lockis deleted (#1899, ADR-022 Iter 2 Phase 2.3a). Foundational fold thedj_activityre-dispatcher + the 2.3b WS-event flip sit on. Two load-bearing flip-scope findings drove this: (1)ViewRuntime._render_lockwas 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_notifyrender 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-CMtransport.event_context(view)on theTransportprotocol + both adapters lets the runtime borrow the consumer's EXISTING lock:WSConsumerTransport.event_contexton enter mirrors_handle_event_innerverbatim —await consumer._render_lock.acquire()(the existing object, not a new one),_processing_user_event = True, set the #1677 origin-channel contextvar toconsumer.channel_name, start aPerformanceTracker+ the SQLcapture_for_eventscope (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_contextis 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_inneris extracted into_dispatch_event_renderand run insideasync 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_innerare UNTOUCHED, anddispatch_url_change/_dispatch_url_change_innerare 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_changeis unaffected. New direct-runtime suitepython/djust/tests/test_transport_event_context_1899.pyasserts the WS context borrows the consumer's EXISTING lock object (held inside, released after — incl. on exception),_processing_user_eventTrue-inside/False-after, origin token set+reset, tracker current-inside/cleared-after; the SSE context is a no-op;ViewRuntimeno 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-opevent_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.
ViewRuntimenow records + persists per-event state the way the bespoke WS_handle_event_innerdoes, so the Phase 2.3 final flip (routing WS events through the runtime) persists identically: (1) time-travel record —record_event_start/record_event_endwrap 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 afinallyso a raising/permission-denied handler still appears in the debug panel; (2) session state-save #1466 —ViewRuntime._persist_state_after_eventmirrors the WS save (private attrs first, then publicget_context_data(), then components), gated on top-level-view identity ANDenable_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 150msasyncio.wait_for(#1475); (3) sticky-child state-save ADR-018 —ViewRuntime._persist_sticky_child_after_eventpersists aview_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 hookon_event_recorded(view, snapshot)replaces the WS_maybe_push_tt_eventdirect send:WSConsumerTransportdelegates to the consumer's existing_maybe_push_tt_event(single-sourcing the DEBUG-gatedtime_travel_eventframe),SSESessionTransportno-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 inwebsocket.pyis UNTOUCHED (the #1466/#1552 grep-pins intest_ws_reconnect_state_1465.py:119/313/320stay green;eventstays out ofRUNTIME_OWNED_VERBS, the WS flip is Phase 2.3). New direct-runtime suitepython/djust/tests/test_runtime_state_save_tt_1894.py(12 cases) drivesruntime.dispatch_eventagainst a MockTransport; each subsystem has a reproduce-first + gate-off pair (#1468) — removing theenable_state_snapshotgate makes a default view wrongly persist (RED), neutering the time-travel record drops the snapshot + hook (RED), and disabling the hook dispatch makes theon_event_recordedassertion 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_idLiveComponent,view_idsticky-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_innernow routes embedded children before the single-view path, mirroring the bespoke WS_handle_event_innersubsystems the runtime previously lacked entirely: (1) aview_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 scopedembedded_update {view_id, html, event_name}frame — the client-suppliedview_idis never echoed into the user-facing error (sanitize_for_login the structuredextraonly, verbatim from WS); (2) acomponent_id-targeted event resolves a child LiveComponent via_components, validates the handler against the COMPONENT (not the parent), notifies the PARENT's waiters withcomponent_idinjected (ADR-002), and emits a parent-scoped full-HTMLcomponent_eventframe — 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-levelwebsocket.render_embedded_child_html, the WS_render_embedded_childis 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_innerrouting is untouched (WS events still flow through it;eventstays out ofRUNTIME_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 suitepython/djust/tests/test_runtime_child_routing_1892.pydrivesruntime.dispatch_eventagainst 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 —
refecho,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_innerhas but the runtime lacked: (1) the clientref(#560) is now echoed back on BOTH the noop and every update frame, coerced to int (type-confusion guard); (2) the noop frame carriessource="event"+event_nameand the update frames carrysource="event"for the client's #560 response-sequencing; (3) a handler that sets_force_full_htmlnow defeats the auto-skip and sends a fullhtml_update(patches discarded, flag consumed), mirroringwebsocket.py:4039-4040; (4)_notify_waiters(ADR-002 Phase 1b) runs after the handler sowait_for_eventfutures resolve on the SSE path too; (5) the #700 identity push-only auto-skip (theid()-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 consumers —websocket.pyis untouched (WS events still use_handle_event_inner;eventstays out ofRUNTIME_OWNED_VERBS, the WS flip is Phase 2.3) — and SSE consumers gain the #560ref/sourcefields. Each grow is reproduce-first + gate-off verified (#1468): new behavioral pins (TestEventSpineRefEcho,TestEventSpineForceFullHtml,TestEventSpineNotifyWaiters,TestEventSpineIdentityPushSkip) and a source-enumeration net (TestEventSpineEnumeration) inpython/djust/tests/test_transport_behavioral_parity.pyso a future drop re-forks RED; a real-SSE-transport end-to-end suite (TestSSEEventSpineParityinpython/djust/tests/test_sse_runtime_convergence_1887.py, driving the/message/endpoint which forwards the fullref-carrying envelope); and an extendedRUNTIME_OWNED_VERBScontract pin (TestRuntimeOwnedVerbsContract::test_event_spine_grown_but_event_not_yet_ws_ownedinpython/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 andViewRuntimepaths carry, i.e. a live instance of the #1646 parallel-path-drift class. Both now dispatch throughsession.runtime.dispatch_mount/dispatch_event— the SAME spine the SSE/message/endpoint and the WSurl_changeshim 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 streampatch/html_updateframes, object-permission denial still blocks the mount (now viadispatch_mount's Iter-0 check), andstart_async/@backgroundwork still streams its result (the runtime grew the async dispatcher SSE needs — this also fixes a latent legacy-SSE drop ofstart_asyncnamed-task work, since the legacy path only dispatched the never-set_async_pendingformat). SSE-specific behavior is preserved via two newSSESessionTransporthooks:build_request()(the runtime mounts against the real HTTP request, not a synthesized userless one) andon_view_mounted()(stamps_sse_session_id/_sse_session/session.view_instance). Nowebsocket.pychanges (WS convergence is Iter 2/3). New end-to-end integration suitepython/djust/tests/test_sse_runtime_convergence_1887.py(mount / event / object-perm /start_asyncvia 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_diffreorders re-render only changed items, flag-gated default-OFF (#1967).Node::Forin 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 onRustLiveViewthat survives acrossrender_with_diffcalls) 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.priceresolve under loop varx→ allowed;prefix/flag→ non-cacheable; tuple-unpackingfor k, vallows bothkandv); 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 %}), anddj-keytemplates. Default OFF (split-foundation #1122 — a hot-path change that must soak); enable viaLIVEVIEW_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. NewTestOutputIdentity/TestCacheBehavior/TestLoopRenderCacheDefaults/TestOuterContextNonCacheableclasses inpython/djust/tests/test_loop_render_cache_1967.py(13 end-to-end viaRustLiveView.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-endrender_with_diffwin 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% ofrender_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 → parsedVec<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), theNode::Forarm 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_diffthen 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 assignsnext_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 (0for an initialparse_html,max(old_ids)+1for a continuingparse_html_continueafter 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), andlast_vdomare 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 residualdj-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 craftedh=(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.attrsnow serialize in SORTED key order (djust_vdom::serialize_attrs_sorted) so the patch wire format is deterministic — a plainHashMapserializes 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 BOTHrender_with_diffandrender_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 inTestParseCacheByteIdentity1970/TestParseCountProbe1970/TestParseCacheSentinelCollision1970(python/djust/tests/test_loop_render_cache_1967.py), theparse_cache_1970module incrates/djust_templates/tests/test_loop_render_cache_1967.rs, andcrates/djust_vdom/tests/test_loop_parse_cache_1970.rs(literal_unnonced_dj_pc_is_not_spliced+ the bare-prefix gate-off).
Fixed
-
dj-virtualnow ships a real layout contract and self-heals across server-driven re-renders — the windowed list scrolls inside adisplay:flexcontainer and survives a live-changing{% for %}source (#1988, #1989). Two entangleddj-virtualgotchas hit in downstream production chat/feed builds. (#1988 — layout)setup()gave the injected shellposition: 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 noflex-shrink, so inside adisplay:flexcontainer its explicitstyle.heightwas crushed tooffsetHeight: 0(defaultflex-shrink: 1) and the list silently never scrolled. The shell is nowposition: 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 isflex-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 andinitVirtualListsno-op'd forever (it tracks setup state in aWeakMapkeyed on the container, whose identity is unchanged) — permanent no-op, recoverable only by manualteardownVirtualList+ 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/refreshVirtualListDETECT 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-leveldj-virtualawareness, out-of-window finalize-patch landing, and automaticstream_append→__djVirtualItemswiring are deferred to follow-up #2017. Client-only change in29-virtual-list.js(+231 B gzipped). 5 new regression cases intests/js/virtual_list.test.js(shell/spacer style contract; full-revert self-heal via the reinit path AND viarefreshVirtualListalone; 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, spaceroffsetHeightunder 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_charswas a sync@backgroundloop doingself.llm_output += ch;_run_async_workawaits 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 anasync defthat pushes each token withawait self.stream_to(..., html=render_markdown(...)), bracketed withstream_start/stream_done+ afinallysettle; the target<article>now carriesdj-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()calledcancel_async("md_stream")but@backgroundregistered the task underfunc.__name__=="_stream_chars", a silent no-op; and a sync@backgroundloop can't be interrupted mid-run. Now scheduled viastart_async(self._stream_chars, name="md_stream")(names match) and async (the loop yields between tokens soresetflipsstreaming=Falsemid-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 executorruntime.py:ViewRuntime._execute_async_task(post ADR-022 convergence, not thewebsocket.py:_run_async_workthe issues cite) unconditionally wrapped callbacks insync_to_async, raisingTypeErrorfor an async callback — so async@background/start_asyncsilently failed on the converged path. Mirrored the consumer twin'siscoroutinefunctioncheck (await async callbacks directly). Docs:streaming-markdown.md's example rewritten to explicit per-chunkstream_*+ cross-linked tostreaming.md; both guides now document thedj-update="ignore"rule for streamed targets and thestream_to()-without-html=full-template-re-render caveat. Tests intest_markdown_stream_demo_2001_2002.pydrive a realWebsocketCommunicator+ runtime path (#1650): the fixed pattern emits >1 content stream op, an in-suite gate-off sibling proves plain mutation emits 0, plusdj-update=ignoreexclusion,cancel_asyncname-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). Adj-window-keydown.escape="close"(or anydj-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 rescandj-shortcut/dj-click-awayalready do), while the window/documentaddEventListenerinstall stays one-shot inside_installScopedDelegation()— so no duplicate listeners accumulate, and the per-elementalreadyRegisteredcheck prevents double-registration / double-fire. Moving the scan out of the one-shot install made the bundle 25 B smaller gzipped. 5 JS tests intests/js/dj-window-rescan-1996.test.js(patch-in reproducer,dj-document-keydownpatch-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 namemorphNode) skipped the server value sync for a focused input/select/textarea unless itsnamechanged, 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-indj-force-valueattribute 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). Everypush_to_viewbroadcast unconditionally reset every<textarea>.valuein 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 markeddj-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'sapplyPatchespath and12-vdom-patch.js'spreserveFormValuesinnerHTML path — route through one sharedsyncBroadcastTextareashelper, so the opt-out lives in exactly one place (parallel-path-drift cure, #1646). +50 B gzipped; 11 JSDOM cases intests/js/form_value_preservation_1990_1991.test.js(direct helper, the real production broadcast path viahandleServerResponse, an anti-drift pin that both sweep sites route through the helper, and INPUT/TEXTAREA focus cases using realdocument.activeElement), gate-off verified (#1468) on both fixes. Documented indocs/website/guides/declarative-ux-attrs.md. -
dj-input.debounce-N(and any.lazy/.debouncesuffix on a non-dj-modeldirective) now warns in debug mode instead of silently never binding (#1999). Onlydj-modelparses the.lazy/.debounce-Nin-name modifier from its attribute name;dj-input/dj-change/dj-clickdebounce via the separate standalonedj-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 (thedj-model.debounce-300-works mental model made it read as a plain non-working feature).bindLiveViewEventsnow runs a debug-gated scan (_warnUnrecognizedDjModifiers, zero cost outsidewindow.djustDebug) that emits aconsole.warnnaming the offending attribute and the standalone-dj-debouncefix. Deliberately scoped to the.lazy/.debouncemodifiers on non-modeldirectives — other legit dotted conventions (dj-keydown.enter,dj-window-keydown.escape,dj-loading.class/.show/.hide/.disable/.for) are untouched. Thedj-modelguide now documents the divergence side-by-side. +315 B gzipped; 7 JS tests intests/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 thesessiontenant resolver: (1)SessionResolver.resolve()is read-only — it never writesrequest.session, and there was noset_tenanthelper anywhere, so switching tenant over a WS event meant hand-writingrequest.session[...]with no built-in save guarantee (a LiveView event has no HTTP response for Django'sSessionMiddlewareto persist against); and (2)TENANT_REQUIRED(defaultTrue) is enforced indispatch()/get()/post()beforemount(), so a fresh session 404s beforemount()can resolve a default. AddsTenantMixin.set_tenant(tenant_id)(inherited byTenantScopedMixin): it updates the authoritative in-memory view state (self.tenant) and best-effort mirrors the id intorequest.session[TENANT_SESSION_KEY]only when a session resolver is configured (a no-op for subdomain/path/header/custom). TheSessionResolverdocstring + the Multi-Tenant guide now document that view state is the WS-lifecycle source of truth, the session write is a mirror only, and thattenant_required=False+ manual resolution inmount()is the correct pattern when a fresh session has no tenant yet. 12 cases inTestSetTenant/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 plaindictafter 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(...)inmount()) is persisted to the session so it survives the HTTP-POST-fallback restore path — which does NOT re-runmount(). That path rannormalize_django_valueover private state (the client-facing serializer), turning the model into the lossy{"pk", "__str__", <fields>}dict, so on restoreself._workspacewas adictandself._workspace.membershipsraisedAttributeError(the reported traceback atmixins/request.pypost()). 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 toNonewith a warning (a stale cached model must not hard-crash a reconnect). 5 tests intest_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 isp.meta.name/presence['meta']['name']. Following the docstring verbatim produced aKeyError/ silently-empty output. Docstring corrected + the record shape documented (#2000). (b){% djust_markdown %}requiresDjustTemplateBackend(it registers only with djust's Rust engine, not Django's stock backend); a plaindjango-admin startprojectTEMPLATESsetup raisesTemplateSyntaxError: 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 fordj-transition) is now repeated in thedj-transition-groupquick-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_CONFIGare 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 readLIVEVIEW_CONFIG, so amax_message_size/rate_limit/event_securityset in the similarly-namedDJUST_CONFIGdict (which already backs tenancy/presence/state-backend, and is easy to confuse withLIVEVIEW_CONFIG) was a silent no-op — e.g. raising the limit viaDJUST_CONFIG = {"max_message_size": 262144}did nothing, no error, no warning. It now falls back toDJUST_CONFIGfor keys that are genuine LiveView config keys (present in the defaults, so unrelated tenancy/presence keys aren't pulled in), withLIVEVIEW_CONFIGwinning on a collision and a debug breadcrumb naming each adopted key. The misleading comment that impliedDJUST_CONFIGwas already handled here is corrected. (2) The upload client'sDEFAULT_CHUNK_SIZEwas64 * 1024— exactly themax_message_sizedefault (65536) — and every chunk frame prepends a 21-byte binary header (buildFrame), so65536 + 21 = 65557 > 65536: a brand-new project using onlyallow_upload(...)with default settings failed any upload past a fractional first chunk withMessage too large (65557 bytes). Reduced to63 * 1024(64512 payload + 21 header = 64533 < 65536). 4 Python config tests (gate-off verified) + a JS source-invariant pin intests/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 — whereinside_unclosed_fencecorrectly 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 publicself.*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 thoughget_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 onselfchanged, just re-render". Callingset_changed_keys()with no arguments now forces a full re-render via the existing_force_full_htmlbypass without naming a key. 4 tests intest_set_changed_keys_zero_arg_1992.pyon the REALViewRuntime.dispatch_eventpath (the test client bypasses thepre==postskip 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-redisextras (and the dev group) now pinredis>=5.0.0,<8— redis-py 8.x crashes the canonicalchannels_redisproduction setup (#1995).channels_redis's receive loop blocks onbzpopmin(timeout=5); redis-py 8.0 changed socket-read-timeout handling on that path, and the resultingredis.exceptions.TimeoutErroris uncaught inside the ASGI consumer — so a djust deployment following the docs verbatim (channels_redisforCHANNEL_LAYERS, required forpush_to_view/presence/cursor/cross-process) shows a flashing "reconnecting" banner every few seconds under multi-process load. The constraint was>=5.0.0,<9in all three sites (pyproject.toml[redis]+[tenants-redis]extras + dev group), too permissive — it allowed 8.x. Pinned<8(verified:redis>=5,<8gives 0 errors in a soak that previously failed within seconds). Defensive tightening — the lock already resolved to 6.4.0, but<9let a future re-lock drift to the crashing 8.x. -
Nested dict/list access in templates (
{{ block.content.text }}on aJSONField) no longer renders silently empty — Django_resolve_lookupparity (#1997).Context::resolve's lazy sidecar getattr walk (crates/djust_core/src/context.rs) didgetattrONLY at each segment, so a dict/list intermediate reached mid-path resolved to empty with no error: for{{ block.content.text }}wherecontentis aJSONField(a plain dict),getattr(dict, "text")raisesAttributeErrorand was swallowed → missing output, zero signal. Django'sVariable._resolve_lookuptries dict item access → attribute → integer list-index at every segment; djust only did the middle step on the sidecar path (the eagerContext::getpath was already dict-aware — #1646 parallel-path drift). The walk now mirrors Django's order (get_item→getattr→get_item(int)). The #1986 serialization-floor proxies implement no__getitem__, so item access on them falls through to the flooredgetattr— verified no floor bypass. Newtest_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
#1678kanban fixture's card-move step was a vacuous guard (#1981). The_snapshot_assignslist-≥100 and dict-≥50 fingerprint-truncation warnings told developers to callself.set_changed_keys({...})— a method that did not exist — and the docstring pointed toself._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#1678client-faithful VDOM fixture's step 2 (cross-column card move) captured 0 patches becauseKanbanTabsView.move_cardmutatedcolumnsin place — a regression guard that exercised nothing.move_cardnow 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>) — CodeQLpy/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</tagfollowed 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). NewTestEndTagWhitespacePreservation(whitespace + newline + bogus-attribute cases, gate-off verified) intest_strip_whitespace.py. -
_run_async_workno longer writes against a stale view on disconnect/re-mount mid-await (#1940).LiveViewConsumer._run_async_workruns as a detachedensure_futuretask that capturesview = self.view_instancebefore its firstawait(the background callback). If adisconnect(which nullsview_instance) or alive_redirect/ re-mount (which reassignsview_instanceto a NEW view) interleaved during that await window, the completed task ran itshandle_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_asyncruns 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 inTestRunAsyncWorkTeardown(python/djust/tests/test_run_async_work_teardown_1940.py), gate-off verified. -
TutorialMixinnow initializes its four internal tutorial-signal attrs in__init__, not thetutorial_total_stepssetter (#1952)._tutorial_active_target,_tutorial_active_class,_tutorial_skip_signal, and_tutorial_cancel_signalwere previously initialized inside thetutorial_total_stepsSETTER, so aTutorialMixinview that never settutorial_total_steps(or read those attrs before the setter ran) hitAttributeError— e.g._cleanup_active_step()(called fromstart_tutorial'sfinallyblock) reads_tutorial_active_target/_class, andskip_tutorial/cancel_tutorialread the skip/cancel signals once running. The four attrs now default toNonein__init__(placed alongside the existing_tutorial_running/_tutorial_current_step/_tutorial_total_stepsinits); 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 inTestSignalAttrsInitializedInInit(read the four signals without invoking the setter; pre-fix raisedAttributeError). -
ComponentMixin.update_componentno longer raisesAttributeErroron aLiveComponent(#1947).update_component(component_id, **props)callscomponent.update(**props), butLiveComponent(a subclass ofContextProviderMixin, NOTComponent) had noupdate()method at runtime, so anyLiveComponentthat did not define its ownupdate()raisedAttributeErroron that path (the latent bug annotated with a# type: ignore[attr-defined]in ADR-023 M4c, part 1).LiveComponentnow has a baseupdate(**kwargs)that sets each prop as an instance attribute (mirroring the Python/hybrid path ofComponent.update) and returnsselffor chaining — the samecomponent.update(**props)API the component docs already document. Subclassupdate()overrides still take precedence. The stale# type: ignore[attr-defined]at the call site is removed. Regression coverage inTestUpdateComponentNoUpdateOverride(tests/unit/test_component_parent_communication.py): bare-LiveComponent update throughupdate_component, base-update()chaining returns self, subclass-override-still-wins. -
Dev-env: detect + recover the
core.bare = trueshared-config corruption that breaks worktree + main checkout (#1938). A linkedgit worktreeshares one.git/configwith the main checkout; if anything flipscore.baretotruethere (a build/PyO3-repoint step runninggit config core.bare true, an IDE/GitKraken integration, or a stray manual command — the #1804/#300 pattern),git status/git pushbreak in BOTH trees (every tracked file shows as deleted). An exhaustive audit confirmed no djust pre-push hook, test, or script writescore.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'sgit initfixtures), and all three were verified empirically to leavecore.bareunchanged — so the corruption is external, not a framework bug. Newscripts/check-shared-git-config.shreadscore.barefrom the SHARED config (resolved via--git-common-dir, works from any worktree), reports a leak (exit 1), and with--fixperforms the documented recovery (core.bare false); it NEVER writescore.bare true. The worktree-subagent mitigation (push--no-verify; CI is the authoritative gate) plus the detector are documented in CONTRIBUTING.md "Working in agit worktree". Tested by 5 cases intests/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_iterwas annotatedAsyncIterator[str]but yields theChunkEmitter'sbyteschunks (the emitterencode("utf-8")s every chunk before queueing, andStreamingHttpResponseis fed bytes) — the strict typing ofhttp_streaming.ChunkEmitter._aiter_impl() -> AsyncIterator[bytes]exposed the mismatch in the (already-strict M4c)mixins/request.py; corrected toAsyncIterator[bytes]. Other gaps (annotation-only, no behavior change):checks/security.pycheck_security's S002@csrf_exemptscan readnode.body[0].value.value(anast.Constant.value, astr | bytes | int | …union) and called.lower()on it — guarded withisinstance(doc, str)so a non-str first-statement constant can'tAttributeError(it never matched"csrf"anyway);_decorator_callable_nametypedOptional[str]so the_is_permission_required_decoratorcomparison stops leakingAny.auth/core.pycheck_view_auth'slogin_url(agetattr(...) or getattr(...)over unstubbed Django)casttostrfor the_check_django_access_mixins(login_url: str)contract;check_redis(djust_doctor) returnsOptional[_CheckResult](it returnsNoneto skip the non-Redis path). Plusbool(...)/str(...)/cast(...)boundary narrowing at the Django-untyped surface (user.has_perms(...),apps.is_installed(...),self.style.SUCCESS(...),json.loads(...),template.render(...),click.prompt(...)) anddict[str, Any]/list[CheckMessage]var annotations where mixed-type literals were inferred too narrowly.cleanup_liveview_sessionsimports the session helpers from their canonical source (djust.session_utils) instead of thelive_viewre-export so the strict island resolves them (equivalently exported vialive_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.packwas annotatedstrwith aNonedefault (a dataclass field lying about nullability —get_state()returnspack=Nonewhen no pack is configured), which also made theThemeState(pack=pack)construction an[arg-type]error againststr | None; corrected tostr | None = None.theming/_registry_accessor.py: theThemeRegistrysingleton's_presets/_themes/_packs/_manifests/_discoveredwere assigned only through a localinstin__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.packwereDesignSystem | None/ThemePack | None(theget_design_system/get_theme_packreturn type) but__init__raises when None, so every laterself.ds.typography/self.pack.icon_styleaccess was aunion-attrerror (14 in pack, 8 in theme) — narrowed by assigning the post-raisenon-None value to aself.ds: DesignSystem/self.pack: ThemePackannotated attr.theming/manager.py: the twoCompleteThemeCSSGeneratorreassignments (ingenerate_critical/deferred_css_for_state) collided with the innerThemePackCSSGeneratorgenvar's inferred type ([assignment]+ a phantom[attr-defined]ongenerate_critical_css) — renamed the inner varpack_gen.theming/palette.py:s_h/a_hmixedint(fromhex_to_hsl) andfloat(fromparams[hue_offset] % 360, where_MODE_PARAMSis inferreddict[str, float]because it mixessat_scale=0.85with integer hue offsets) — wrapped the (always-integer) hue offsets inint(...)to keeps_h/a_hint(int(180) % 360is identical).theming/build_themes.py:build_alldeclared-> Dict[str, str]butartifacts["individual_themes"]is alist[str]— the return type lied about the heterogeneous shape; corrected toDict[str, Any](+ themanifest/artifactslocals annotated).theming/accessibility.py: afloat ** floatreturnedAny([no-any-return]) and abool-orchain over unstubbed-attr comparisons returnedAny— wrapped infloat(...)/bool(...).theming/mixins.py: the conditional-importevent_handlerfallback was an unguarded[no-redef](narrow# type: ignore[no-redef]),_theme_managerwasThemeManagerwith aNonedefault (corrected toThemeManager | None), and the four event handlers gainedif self._theme_manager is None: returnguards so the_theme_manager.set_mode(...)accesses type-check (no-ops post-mount, matching_setup_theme_context's existing guard). Plus severalmark_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_passwordhad implicit-Optional defaults (field: str = None) that PEP 484 prohibits — corrected toOptional[str]; andModelCreateView.mountoverrodeModelDetailView.mount(self, request, object_id=None, ...)with a narrowermount(self, request, **kwargs)signature (an LSP[override]violation) — restored theobject_idparameter (still forced toNoneinternally, so the create view's "always start with no object" behavior is unchanged) so the override is contract-compatible.admin_ext/options.py: severalwarn_return_anyleaks at the Django-untyped boundary narrowed at the return site —_widget_has_permissionreturnsbool(user.has_perms(...)),get_formpinsmodelform_factory(...)to a typed local, andget_field_display_namewraps theverbose_name/short_descriptionreads instr(...).admin_ext/plugins.py:NavItem.has_permission/AdminWidget.has_permission/AdminWidget.rendersimilarly narrowed (bool(request.user.has_perm(...)),str(render_to_string(...))).admin_ext/__init__.pyautodiscoverandadmin_ext/apps.pyDjustAdminConfig.readygained explicit-> Nonereturns. -
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.pyComponentMixin.update_componentcallscomponent.update(**props)afterisinstance(component, LiveComponent), butLiveComponent(which subclassesContextProviderMixin, NOTComponent) has noupdatemethod at runtime — confirmed via the live MRO (Component.updateexists;LiveComponent.updatedoes not). Soupdate_component()would raiseAttributeErrorif ever invoked with aLiveComponent. The strict flip carries a narrow# type: ignore[attr-defined]with a comment at the call site; the fix (moveupdateontoLiveComponent, or change the routing) is left for a dedicated bugfix PR sincemixins/M4c(1) is annotation-only. Other gaps (annotation-only, no behavior change):mixins/page_metadata.py_pending_page_metadata/_drain_page_metadatawereList[Dict](incomplete generic) →List[Dict[str, str]].mixins/model_binding.pyallowed_model_fieldsclass attr was inferredNone(from= None) → annotatedOptional[List[str]](the true subclass-override contract);_dj_model_fieldsbarefrozenset→frozenset[str].mixins/rust_bridge.pyrendered_context = {}was inferredDict[str, dict[str, Any]]from its first (dict-valued) assignment, breaking later primitive/str assignments → annotatedDict[str, Any](no logic change; the change-detection path is byte-identical).mixins/template.pypos = open_pos + 4mixed thefloat("inf")sentinel into anintaccumulator → narrowedint(open_pos)in the branch whereopen_pos < close_posguarantees a real int;_current_html_size/_previous_html_sizedeclaredOptional[int]to match thegetattr(..., None)first-render seed.mixins/jit.py_variable_extraction_cachewasDict[str, dict]but storesOptional[dict]→Dict[str, Optional[dict]]; theif not extract_template_variablestruthy-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 builtinanyfunction used as a type — a typo) corrected toDict[str, Any].components/ui/modal_simple.py:Modal._render_customreadself.showbut__init__never assigned it (theshow=kwarg was passed tosuper().__init__but not re-set as an instance attr like its siblingsbody/title/etc.) —[attr-defined]against theRustModal-instance path; addedself.show = showto 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.templatereads were typedOptional[str](the baseComponent.template: Optional[str]class attr) while the subclass always sets astr— narrowed via atemplate = self.template or ""local at the top of_render_custom.components/ui/navbar_simple.py: the nav-itemsparam was annotatedList[Dict[str, Union[str, bool, List[...]]]]which mis-typed the nested-dropdownaccess as non-iterable (union-attron.get/__iter__) — widened toList[Dict[str, Any]](the honest contract for heterogeneous dynamically-accessed dicts, #1108). Float/int local-init mismatches in chart/heatmap/pivot renderers (total = 0→0.0,y = ...→y: float = ...,row_total/col_totals/grand_total→ float) where a numeric accumulator was seededintthen+='d a float (output via:.1f/_format_valis identical).components/gallery/registry.py:cat = info.get("category", "misc")wasobject-typed (from the heterogeneous EXAMPLES literal) socat.title()wasattr-defined/call-overload— coercedcat = str(...)(category is always a str). Numerous list/dict locals across data_table/templatetags annotated to fix mixed-elementvar-annotated(e.g.pages: listmixing 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 nestedState.active) was annotatedstr, but inmultiple=Truemode it holds a list of open item ids — soactives.remove(value)/actives.append(value)/state.active = [value]were[attr-defined]/[assignment]errors against the declaredstr. Corrected toUnion[str, List[str]](the true runtime contract — single id when single, list when multiple), narrowing the list branch withcast(List[str], inst.active)(mixin, guarded byinst.multiple) / the existingisinstance(actives, list)(descriptor). The 8 deprecated state mixins (tooltip/tabs/sheet/modal/dropdown/collapsible/carousel/accordion) hadcomponent_id = self._resolve_component_id(component_id)reassign anOptional[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 theinst is Noneguard). Typed all*_instancesclass vars (Optional[Dict[str, XState]]) and the descriptor_handle_event(self, state: "State", ...)params (the nested-State-subclass forward-ref, not the baseTypedState, sostate.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_toastcallsself.push_event(...), a method supplied by the hostLiveView(viaPushEventsMixin) and absent from the standalone mixin — mypy flagged[attr-defined]; declared the cooperating method underif TYPE_CHECKING:(the canonical djust mixin pattern, mirrorsstreaming.py), no runtime change.components/function_component.py:{% call %}dispatch setinstance._slots/instance._childrenon aLiveComponent(per-invocation template-render attrs, distinct from the class-levelslotsdeclaration list) — narrow# type: ignore[attr-defined]with an explanatory comment; the@componentdecorator's_djust_*metadata stamps on a plainCallablelikewise narrowed.components/presets.py:_BUTTON_PRESETS(heterogeneousstr/boolvalues) was inferreddict[str, object], making the built-inregister_preset(...)registration loop an[arg-type]error — annotatedDict[str, Dict[str, Any]].components/mixins/base.py:TypedState.__init__calleddefault.fget(self)whereproperty.fgetisOptional— added thefget is not Noneguard (behavior-preserving for every real_make_property-built property).components/utils.py+components/icons.py+components/suspense.py+components/templatetags/_registry.py: severalAny-leaks at Django boundaries (col.get(...)/value.strftime(...)/mark_safe(...)/conditional_escape(...)/render_to_string(...)) narrowed tostrat the boundary sowarn_return_anyis satisfied without anAnyescape. -
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_moduleswas annotatedDict[str, str]but everyregister()stores a nested{"module": ..., "export": ...}dict — the field annotation contradicted the (correct) return types ofget_module_info()/get_all_modules(); corrected toDict[str, Dict[str, str]].presence.py:broadcast_to_presence(event, payload: Dict[str, Any] = None)declared a non-Optionalparam with aNonedefault (the body already coalescespayload = {}) — corrected toOptional[Dict[str, Any]] = None.testing.py:assert_routed_views_allowedimported_routed_liveview_classesfromdjust.checks, where it is not re-exported (it lives indjust.checks.components) — corrected to import from the defining submodule (verified importable).performance.py:PerformanceTracker.root_node/current_nodewere inferredNone-only from__init__then reassignedTimingNode— declaredOptional[TimingNode], and the_find_parent_nodecall now guardsroot_node(was guarding onlycurrent_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._requestwas assigned (DjustSSEStreamView.get) and read (runtime.SSESessionTransport.build_request) but never declared in__init__— added theOptional[Any]declaration alongside_event_request.runtime.py:_instantiate_error_framewas first-assignedNonethen a dict (mypy inferredNone-only, so the dict assignments were errors) — declaredOptional[Dict[str, Any]]in__init__;_instantiate_viewcalledOptional[type]()("None not callable") — added theViewResolution.__bool__-impliedview_class is Noneguard; the dormant actor-mount path calledOptional[create_session_actor]— added the actor-availability guard.websocket.py:_recovery_htmlwasstr-typed from its first assignment but cleared toNoneon one-time use — annotatedOptional[str].live_view.pyi: the M2-island stub omitted the module-level_FRAMEWORK_INTERNAL_ATTRSthatwebsocket._snapshot_assignsimports — added it (a stub-completeness gap the M3 flip surfaced because websocket now resolves the import against the strict stub). SeveralAny-leaks at Django/PyO3 boundaries narrowed at the boundary (bool()/str()/int()wraps onvalidate_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_ATTRIBUTESwas annotatedSet[str](mutable) but holds afrozenset— the annotation lied about mutability for a membership-only, never-mutated security denylist; corrected tofrozenset[str], matching the immutable-denylist intent.security/log_sanitizer.py:sanitize_dict_for_log'sresultdict holds heterogeneous values (redacted strings, nested sanitized dicts, sanitized item lists) under an inferreddict[str, str]— annotateddict[str, Any].security/state_snapshot.py(sign_snapshot) andpermissions.py(dump_starter_document):[no-any-return]from untyped-dependency calls (TimestampSigner.sign,yaml.safe_dump) narrowed tostrat the boundary. Plus annotation gaps closed inrate_limit,_context_provider,schema,permissions, andtest_isolation(missing return/param annotations +var-annotatedhints). 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_handlerbare 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@overloadso both forms type correctly for downstream consumers.live_view._is_serializable:_non_serializablewas 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 — annotatedtuple[type, ...].live_view.pyi: thestreamstub was missing thelimitparam present onStreamsMixin.stream(stub-vs-source signature drift, the #1646 class) — added.mixins/handlers.py:_handler_metadatahad no base annotation, so its inferred non-optionaldictconflicted withLiveView.__init__'sNoneinit — annotatedOptional[Dict[str, Dict[str, Any]]]to match the runtime contract (theis not Nonecache 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_redirectto a non-LiveView path now falls back to a full-page navigation instead of stranding the page (#1934). Withauto_navigatedefaulting ON in v1.1, alive_redirectwhose target is a plain Django view (e.g. aTemplateView) 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 inhandleLiveRedirect(python/djust/static/djust/src/18-navigation.js): (1) thepushStatefired 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 usedresolveViewPath(), 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-viewlive_redirectto 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_urlguard also returnsNonefor a non-LiveView URL and keeps the stale client-supplied view, so the client must make the full-nav decision. Fix: a new STRICTresolveLiveViewPath()(route map ONLY, no container fallback) drives the cross-view decision; thepushState+ 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 throughwindow.djust.safeNavigationTarget(mirroring the existing cross-origin branch, with thesafeNavigationTargetopen-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 indescribe('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, andLiveView 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_workunder parallel-n autoby 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 viastart_async()inmount(), then asserts the callback ran (view.value == 42). The runtime dispatches that callback FIRE-AND-FORGET viaasyncio.ensure_future(self._execute_async_task(...))insideViewRuntime._dispatch_async_work(python/djust/runtime.py:4444), and_execute_async_taskITSELF awaits async_to_async(callback)thread-pool round-trip before settingvalue=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 whilevaluewas 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 (inpython/djust/tests/test_transport_behavioral_parity.py, test-only — production unchanged): wrap the runtime'sasyncio.ensure_futureseam duringdispatch_mountto capture the EXACT_execute_async_tasktask handle it spawns (the_flush_push_eventsfire-and-forget send, which uses the same primitive, is excluded by coroutine name so the gate-off stays sharp), thenawait 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 indispatch_mountspawns no_execute_async_task→assert async_work_tasksfails (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 intest_mount_dispatches_async_work. -
De-flaked the six rate-limit burst-exhaustion tests under
-n autoby OWNING THE CLOCK —test_ping_flood_triggers_disconnectno longer flakes on a wall-clock token refill (#1930).TestRateLimiter+TestGlobalRateLimit(tests/unit/test_event_security.py) build test-localTokenBucket/ConnectionRateLimiterinstances and assert burst exhaustion (e.g.rate=100, burst=2→ the 3rdcheck()must beFalse).TokenBucketreadtime.monotonic()directly, so under CPU-saturated parallelmake testreal wall-clock elapsed betweenconsume()calls and the refill mathtokens + elapsed * rate(rate=100 = 1 token / 10ms) added a token back — flipping a "burst exhausted → False" assertion non-deterministically toTrue. 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.monotonicmodule-level seam inpython/djust/rate_limit.py(production behavior identical; one indirection) routesTokenBucket.__init__+consume()through a patchable name WITHOUT patching the globaltimemodule; aFakeClock+frozen_clockpytest fixture monkeypatchesdjust.rate_limit._monotonicso the five burst-exhaustion tests run on a FROZEN clock (elapsed == 0→ no refill → deterministic), andtest_token_bucket_refillsreplacestime.sleep(0.05)withfrozen_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 checkFalse → 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 refilladvance()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 inTestRateLimiterandTestGlobalRateLimit. -
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 there.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'saddEventListener/ init never ran — with NO console error. This is why #1871'swindow.djust._runInsertedScriptsmount-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__smokeTabsWiredstayedundefineduntil 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 inTestStripCommentsAndWhitespace(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-blockingbrowser-smokeCI 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_authclosed the socket with code4403UNCONDITIONALLY on thepermission_deniedverdict, while gating the redirect verdicts (login-required /on_mountredirect) onnot mounting_in_batch(the #291/#1780 multiplexed-path rule). Inside amount_batchthe 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 bespokehandle_mountwhich also closed unconditionally). Thepermission_deniedclose is now gated onnot self.mounting_in_batchtoo, so all blocking mount-auth verdicts share one batch-aware close. No security loss: the denied view is NOT mounted regardless — the runtime sends theerror(permission_denied) frame and clearsview_instanceBEFOREfinalize_mount_authruns; only the transport-level socket close is suppressed in the batch case, so the denied view simply reports infailed[]exactly as the redirect case already reports innavigate[]. The denial holds; the siblings (which the client IS authorized for) are no longer dropped. A SINGLE (non-batch) denied mount STILL closes4403(mounting_in_batchisFalseoutside a batch). New cases intest_ws_auth_close_socket.py(realWebsocketCommunicator, 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) andtest_single_objperm_denied_mount_still_closes_socket(over-gating guard). Gate-off (#1468) verified: reinstating the unconditionalpermission_deniedclose makes the batched-denial test go RED (the ping openness probe receiveswebsocket.closeinstead ofpong); 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_updateattached three things a runtime-routed WS event (which sends viatransport.senddirectly) dropped — (1) the per-event_debugdebug-panel payload (_attach_debug_payload, DEBUG +_debug_panel_activegated) plus the top-leveltiming/performancefields (gated on_should_expose_timing()= DEBUG orDJUST_EXPOSE_TIMING); (2) theno_patchescontext_snapshotthe bespoke path passed to_emit_full_html_update; and (3) the cosmetic_current_event_name/_current_event_refconsumer attrs. A newTransport.on_event_frame(view, frame, *, event_name, event_ref)hook (SSE no-op) — called by_render_and_sendin-place just before everypatch/html_updateevent frame — attaches (1) via the consumer's existing_attach_debug_payload+_should_expose_timing(verbatim bespoke gate;performancefrom theevent_context-borrowedPerformanceTracker;timing.renderfrom a render-duration measured per event) and stamps (3);on_render_emittedgrew acontextparam so theno_patchesbranch threadsget_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_msmarker is always popped before send and never reaches the wire. (#1921) dead code: theLiveViewConsumer._extract_cache_config/_extract_optimistic_rulescopies had ZERO callers after the mount flip deleted thehandle_mountbody that called them (orphan-grep confirmed acrosspython/+tests/);ViewRuntimeowns the live copies the mount frame uses. Removed; the runtime docstrings' stale "Mirror ofLiveViewConsumer._extract_*" refs are corrected. No change toRUNTIME_OWNED_VERBS/ routing; SSE unaffected. New cases inTestResidualFoldObservability+TestDebugResidualOnEventFrame(python/djust/tests/test_ws_event_flip_parity_1896.py): real-WebsocketCommunicatorDEBUG-vs-PRODUCTION parity (a DEBUG event frame carries_debug,timingunder expose-timing; a prod frame carries NEITHER_debug/timing/performancenor 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 theon_event_framefold + the context threading off makes the 8 behavior-meaningful tests RED. -
The SSE
/event/alias now forwards the client-sentrefso the #560 ref echo works on BOTH SSE endpoints (#1891). The/message/endpoint forwards the raw body verbatim toruntime.dispatch_message, so a client-supplied top-levelrefreacheddispatch_eventand 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 DROPPEDref— so the runtime's_dispatch_event_render(which readsreffrom the top level of the data dict) sawNoneand echoed nothing, leaving the end-to-end ref echo exercised only via/message/.DjustSSEEventView.postnow carriesrefthrough into the dispatch frame ({type, event, params, ref}); the runtime coerces it to int / None, so no endpoint-side validation is needed.paramsalready carried_cacheRequestId/component_id/view_id(SSE has neither component nor sticky-child routing), sorefwas the only dropped field. New cases inTestSSEEventAliasRefEcho(python/djust/tests/test_sse_runtime_convergence_1887.py, real-SSE end-to-end: update + noop frames echo the ref over the/event/alias) andTestDjustSSEEventViewPost::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-dropsrefto confirm absence) stays green. -
component_id-routed WebSocket events now re-render the parent and emithtml_updateinstead of erroring (#1898, fixed by #1907 THE FLIP). The deleted bespoke_handle_event_innercomponent_idbranch resolved + ran the LiveComponent handler but never re-rendered the parent view:htmlstayedNone, the html_update fallback strippedNoneand raisedTypeError, andhandle_exceptionturned it into anerrorframe — so a working component event surfaced to the client as an error with no DOM update. Now that WS events route throughViewRuntime.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-scopedhtml_updatecarrying the parent's updated state (e.g. values pushed up viasend_parent), and echoes the eventref. The#1896parity net'scomponent_idtest is updatederror→html_update(the single intended behavioral change of the flip); its gate-off sibling (a boguscomponent_idstill errorsComponent not foundat 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_layoutswaps, accessibility announcements, and i18n commands queued duringhandle_params()(a live parallel-path-drift instance, #1646, INSIDE the convergence target). The runtime now has a single_flush_all_pendingthat drains all 8 queues in WebSocket's exact canonical order (mirrorswebsocket.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,TestWsOnlyBehaviorEnumerationinpython/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_URLCONFleak, PR #1874), #1875 (djust_hotreloadchannel-layer pollution, PR #1881), and #1882 (process-global wire-version drift — a straydjust_hotreloadframe on the cachedInMemoryChannelLayerre-renders on a later consumer and bumps its per-connection_next_version()counter, sotest_time_travel_jump_recovery_version_is_currentsaw 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 helperdjust.test_isolation.reset_djust_globals()(DRY, #1646) called by an autouse_reset_djust_globalsfixture in BOTH test roots (tests/conftest.py, mirroringcleanup_session_cache; andpython/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-levelitertools.countid 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 touchstate_backend(already isolated bycleanup_session_cache), the keyed self-invalidating_jit_serializer_cache, the one-shot_CUSTOM_FILTERS_BRIDGEDbootstrap, or per-instanceStickyChildRegistry._child_views. The #1882 cure is proven deterministically + gate-off (#1468) inpython/djust/tests/test_global_isolation_1883.py: a stale-layer siblinggroup_sendreproduces the exactgot 4drift WITHOUT the reset and the clean1 -> 2 -> 3chain WITH it, plus per-global unit pins (neuteringreset_djust_globalsfails 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
#1721theme-tag tests under-n auto— the systemic#1883fixture now re-asserts theready()-time Rust tag handlers (#1928, #1883-class).python/djust/tests/test_theme_tags_rust_engine_1721.pyflaked under full-n auto:has_tag_handler("theme_panel")returnedFalseand all 17 tests 500'd withUnsupported 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, andDjustThemingConfig.ready()/DjustComponentsConfig.ready()register the{% theme_X %}/{% render_slot %}handlers only ONCE per process.tests/benchmarks/test_tag_registry.py::TestRustPythonInteropclears the registry (clear_tag_handlers()) and itsrestore_registryfixture restores ONLY thedjust.template_tagsbuilt-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 thatdjango.setup()s withoutdjust.theming). This is the exact #1771 bug fixed only intests/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 bothready()-time registrars BEFORE every test in both test roots — idempotent (theming guards onhas_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 inpython/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_socketunder-n auto(#1875). The #291 regression test (a login-redirecting view in amount_batchmust NOTclose()the shared socket) was order-fragile under full-n autosaturation — 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-globaldjust_hotreloadchannel-layer group on connect, so a sibling test'sgroup_send("djust_hotreload", ...)could deliver a stray frame into the test'sreceive_nothingwindow — now isolated by clearing the cached channel-layer backend so the consumer connects to a fresh, unpollutedInMemoryChannelLayer; (2) thereceive_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 deterministicping→pongopenness probe (a closed socket cannot pong). Gate-off verified (#1468): removing the_mounting_in_batchclose-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. -
V004no longer false-fires on framework-invoked lifecycle hooks (#1684). TheV004system 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 theV004lifecycle-skip set inchecks/components.py. Canonical symptom:handle_presence_leave(bitdjust-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 the1.1branch (#1685) against the pre-#1822-splitchecks.py; it was never ported tomain's splitchecks/(so the false-positive was live through 1.0.8) — this lands it onmain. New regressionTestV004LifecycleMethods::test_v004_ignores_framework_invoked_hooks_1684(gate-off verified, #1468). -
djust newscaffold'ssettings.pytemplate now readsDJUST_SQLITE_PATHfor the SQLiteNAME, falling back toBASE_DIR / "db.sqlite3". A scaffolded app's default SQLite database lived underBASE_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 asDJUST_SQLITE_PATHand 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_diffreorders re-render only changed items, flag-gated default-OFF (#1967).Node::Forin 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 onRustLiveViewthat survives acrossrender_with_diffcalls) 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.priceresolve under loop varx→ allowed;prefix/flag→ non-cacheable; tuple-unpackingfor k, vallows bothkandv); 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 %}), anddj-keytemplates. Default OFF (split-foundation #1122 — a hot-path change that must soak); enable viaLIVEVIEW_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. NewTestOutputIdentity/TestCacheBehavior/TestLoopRenderCacheDefaults/TestOuterContextNonCacheableclasses inpython/djust/tests/test_loop_render_cache_1967.py(13 end-to-end viaRustLiveView.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-endrender_with_diffwin 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% ofrender_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 → parsedVec<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), theNode::Forarm 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_diffthen 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 assignsnext_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 (0for an initialparse_html,max(old_ids)+1for a continuingparse_html_continueafter 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), andlast_vdomare 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 residualdj-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 craftedh=(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.attrsnow serialize in SORTED key order (djust_vdom::serialize_attrs_sorted) so the patch wire format is deterministic — a plainHashMapserializes 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 BOTHrender_with_diffandrender_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 inTestParseCacheByteIdentity1970/TestParseCountProbe1970/TestParseCacheSentinelCollision1970(python/djust/tests/test_loop_render_cache_1967.py), theparse_cache_1970module incrates/djust_templates/tests/test_loop_render_cache_1967.rs, andcrates/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 (viaengine.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 viaLIVEVIEW_CONFIG['filter_bridge_warm'] = False. New cases intest_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/blockrender()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]]withignore_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 thatcasts Django's@keep_lazy-decorated (untyped →Any)mark_safetostr, absorbing the ~200-strongno-any-returncascade across every handler return without ignores; (b) inlinecast(...)/str(...)(runtime no-ops) at eachint()/float()/dict-key/attribute site of thekw.get(...) -> objectcascade, plus a handful of explicitvar: 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 (thecast/strcoercions are runtime no-ops; the onlystr()wraps that touch lookup keys were converted tocastto guarantee key identity).mypy python/djuststays GREEN (822 files) withdjust.components.rust_handlersstrict; gate-off-verified (#1468) — a wrong-typedintreturn injected into a handler (ModalHandler.render, declaredstr) 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. Seedocs/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]]withignore_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 underscoretemplate_tags/package, distinct from the Django-enginetemplatetags/package already flipped in M4d group 1); and the theme-gallery / component-storybook surface (theming/gallery/—viewsthe gallery/editor/diff + storybook DEBUG/staff-gated views,contextthe example-context + token-serialization builders,component_registry,urls,storybook). None of the three subpackages has atests/dir, so the ratchet completes each in one PR with no test sub-package to defer. Annotated with real types (params + returns — notAnycosmetics):HttpRequest/HttpResponseon the gallery views,list[dict[str, Any]]on the example builders,Callable[[Type[TagHandler]], Type[TagHandler]]on the@registerdecorator factory. Render output is byte-identical — the SafeString/HTML boundaries (format_htmlinflash,escapeinmarkdown,Template.renderinpwa,reverseinurl,staticinstatic,_client_config_htmlinclient_config, the dynamic component.render()incomponent_registry) returnAnyunder the lenient global config (Django + the cross-islandlive_tags._client_config_htmlare seen as untyped), so each is coerced withstr(...)at the boundary to satisfywarn_return_anyWITHOUT changing the returned (already-safe) HTML. One real type fix:scaffolding/generator.pylist_display_fieldsannotatedlist[str](was an un-annotated[]flaggedvar-annotated).mypy python/djuststays GREEN (822 files) with all 20 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typedintreturn injected intotemplate_tags/url.UrlTagHandler.render(declaredstr) turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed. Seedocs/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 thedjust.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-levelColorScale/ThemeTokens/ThemePreset/DesignSystem/ThemePackliterals, zero functions), the dependency-free re-export hub_base, the package__init__(pure re-exports), and the deprecated_legacymodule (theTheme/THEMESdataclass 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 untypeddictoverrides (__getitem__/__contains__/get/items/keys/values/__iter__/__len__) — annotated to match thedict[str, Theme]superclass signatures (the three view methods declare-> Anyfor the un-nameable concretedict_items/dict_keys/dict_valuesreturn 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/djuststays GREEN (822 files) withtheming/themes/*strict; gate-off-verified (#1468) two ways — a wrong-typedstrreturn on_legacy._DeprecatedThemesDict.__len__(declaredint) 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-leveltheming/modules are already strict (M4c part 3), but mypy'sdjust.theming.*glob matches only direct children, not the deeperdjust.theming.themes.Xsubmodules, so this subpackage needed its own override entry. Seedocs/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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans the loose top-level modules (appsDjustConfig,audit_astAST security-audit walker,audit_liveruntime auditor,bug_captureharness,checks_css_proposalproposed CSS system-checks,hookslifecycle registry,hot_view_replacementHVR engine,state_backend/template_backendback-compat re-export shims,template_filtershelpers,time_travelrecorder,utilsshared 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 — notAnycosmetics):db/decorators.notify_on_save.decoratetypedtype[models.Model]so_meta/labelresolve, with narrow# type: ignore[attr-defined]s on the dynamic_djust_notify_channel/_djust_notify_receiversintrospection attrs stashed on/deleted from the decorated model class; the signal receivers_on_save/_on_deleteannotated(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_backendnowcast(PresenceBackend, _registry.get())mirroring the already-strictstate_backends.registrypattern (the generic registry returnsAny);backends.redis.RedisPresenceBackend.countwraps the untypedzcountAny-return inint(...);db.notifications._import_psycopggained its-> tuple[Any, Any]return; anddb.notifications._dsn_from_url's URL-field loop variable was renamed (val→dsn_val) to stop colliding with the earlierstr-typedparse_qslloop var so the mixedstr | int | Nonefield tuple type-checks.mypy python/djuststays GREEN (822 files); gate-off-verified (#1468) — a wrong-typedintreturn inbackends.registry.get_presence_backend(declaredPresenceBackend) 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. Seedocs/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_componentswas 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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans the four template-tag modules (theme_components— the ~26 themed component tagstheme_button/theme_card/theme_alert/theme_input/theme_modal/theme_table/theme_nav/etc.;theme_pages— the auth/error/utility page-fragment tagstheme_login_page/theme_404_page/theme_maintenance_page/etc.;theme_tags— thetheme_head/theme_css/theme_switcher/theme_preset/theme_modeaccessors + the sharedbuild_theme_head_contextbuilder;theme_form_tags—theme_form/theme_form_errors/get_css_prefix) and thedjust_thememanagement 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 theirmark_safe/format_htmlreturn values are annotatedSafeString(the HTML-safe boundary) andcontext/request/formparams getContext/HttpRequest | None/BaseForm; output is byte-identical. The management command uses the establishedCommandParser/*args: Any, **options: Anyshape mirroringdjust_setup_css/djust_doctor. Four real type fixes to clean the islands (the kind strict-flips surface, ADR-023):theme_components.theme_progressannotatespercentage: float(themin(100, (int(value)/int(max))*100)reassignment is afloat; the= 0seed inferredint→[assignment]);theme_tags.theme_framework_overridesnarrows theformat_htmlresult through astrlocal at the unstubbed-django boundary ([no-any-return]); the three_css_prefix()helpers +theme_pages._csrf_token_valuewrap the untypedget_theme_config().get(...)/get_token(...)boundary instr(...); anddjust_theme.handle_marketplace_inforeads the required-positionalmp_theme_namevia subscript (not.get()) so it stays non-Optionalfor thethemes_dir / theme_namePath division +get_component_coverage(str, ...)call ([operator]/[arg-type]).mypy python/djuststays GREEN (822 files) with all 8 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typedintreturn intheme_pages._css_prefix(declaredstr) turns the gate RED ([return-value]), reverting restores GREEN. Behavior is byte-identical (annotations + thestr(...)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 optionaltheming/gallerysubpackage, which remains for a continuation batch. Seedocs/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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans the PWA layer (mixinsPWAMixin/OfflineMixin/SyncMixin,storageoffline backends +OfflineAction/SyncQueue,syncSyncManager/ConflictResolver,manifest,service_worker,utils), the optimization layer (fingerprintStateFingerprint/SectionCache/IncrementalStateSync,codegenserializer code-gen,query_optimizerselect/prefetch analysis,cacheSerializerCache,__init__), the multi-tenant layer (resolvers,managersTenantManager/TenantQuerySet,backendsredis/memory presence,middlewareContextVar tenant binding,mixinTenantMixin/TenantScopedMixin,audit,security,models,__init__— annotations only; tenant-isolation logic byte-identical), and the observability layer (viewslocalhost-gated endpoints,middlewarelocalhost gate,sql/timings/log_handler/tracebackscapture buffers,dry_runside-effect blocker,registry,urls,__init__). Annotated with real types (params + returns — notAnycosmetics), using the established mixin-collaborator pattern (# type: ignore[misc]on cooperativesuper().get_context_data()/dispatch()calls mirroringwizard.py/tenants;TYPE_CHECKING-onlypush_event/sync_queuestubs on the PWA mixins documenting the co-mixed-LiveViewcontract) and a narrow# type: ignore[import-untyped]ondry_run's lazyimport requests(a known-stub package mypy won't silence viaignore_missing_imports). Two real bugs fixed to clean the islands (the kind strict-flips surface, ADR-023):pwa.storage.OfflineAction.idwidened toUnion[str, int](callers forward an int model pk asobj_id; theSyncQueueaction-id params widened to match), andpwa.mixins.delete_offlinenow passes the requiredOfflineAction(data={})— omitting it raisedTypeErrorat runtime on every call (a guaranteed crash in an untested path).mypy python/djuststays GREEN (822 files) with all 31 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typedstrreturn inoptimization.fingerprint.StateFingerprint.version(declaredint) 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: thepwa/{templatetags,management}-style leaf packages do not exist for these four subpackages, so M4d(2) completes their non-test surface. Seedocs/adr/023-incremental-type-enforcement.md. -
Strict type enforcement on the
tutorials/,api/,template/, andstate_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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans the declarative guided-tour state machine (tutorials/— theTutorialStepdataclass +TutorialMixinasync tour loop, withif 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_functiondispatch views, the pluggableBaseAuth/SessionAuthcontract, 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_contentsloaders, theDjustTemplaterendering pipeline incl. the{% extends %}/{% block %}parser +{% url %}resolver, and theserialize_value→JSONValueserializer), and the LiveView state-persistence backends (state_backends/— theStateBackendABC, the in-memory + Redis backends, and the registry).api/andstate_backends/are security/correctness-relevant — annotations only, logic byte-identical: the_snapshot_assigns/_compute_changed_keysdiff, 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.pyiwire-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-JITDjangoJSONEncoder = None/_get_model_hash = Noneimport fallbacks intemplate/rendering.py; the transientNone-view health-check probe entry instate_backends/memory.py);cast(...)is used at the Django/zstd/Rust unstubbed-boundaryAnyleaks, andassert ... is not Nonenarrows already-guarded optionals (thenext_start.end()block-parser sites, the_get_compressor()compress path gated by_compression_enabled).mypy python/djuststays GREEN (822 files) with all 20 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typedintreturn instate_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 atests/subdir, so the ratchet completes each in a single PR (no test sub-package to defer). Seedocs/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]]withignore_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_introspecthelper; the full Django system-check family (configuration/security/templates/quality/components/integrations/accessibility+ the sharedutils); the auth layer (thecheck_view_auth/run_pre_mount_auth/enforce_object_permissionsecurity core, theLoginRequiredLiveViewMixin/PermissionRequiredLiveViewMixin, thesocial_auth_providerscontext processor, the signup/loginviews+forms, and thedjust_adminplugin + itsOAuthProvidersView/SocialAccountsViewLiveView pages); all five template-tag modules (live_tags— the big one with{% live_render %}/{% colocated_hook %}/{% dj_activity %}+ the lazy-thunk emitter, plusdjust_flash/djust_formsets/djust_pwa/djust_tutorials); and the loose modulescli,dev_server,deploy_cli,drafts,http_streaming,session_utils,push,middleware. Annotated with real types (params + returns — notAnycosmetics):SafeStringat themark_safe/format_htmlboundary;CheckMessagefor system-checkerrorslists + returns;argparse.Namespace/CommandParserfor the management commands;ast.*node types (ast.ClassDef/ast.Call/ast.expr/ast.Module) for the AST-based checks;AsyncIterator[bytes]for theChunkEmitterstreaming surface. The only narrow coded# type: ignores are at genuine dynamic edges: thedjust.checkssetattrre-export (_root.*— the patch-by-path contract from the #1822 monolith split), thedjust-adminoptional-dependency fallback class (no-redef/assignment), the optional_rustversionexport (not in the.pyi), the auth-mixin cooperativesuper().dispatch(provided by the combined View), and the Djangomodel._metaaccess (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/djuststays GREEN (822 files); gate-off-verified (#1468) — a wrong-typed return in a flipped module (templatetags/djust_flash.dj_flash→int) turns the gate RED ([no-any-return]), reverting restores GREEN. Full suite 8604 passed / 0 failed.requests(consumed bydeploy_cli) joinsyamlin the untyped-third-party override. Remaining for a continuation batch: themanagement/templatetags-adjacent long tail is already covered; thetenants/,backends/, andstate_backends/subpackages + the last few loose modules remain. Seedocs/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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans the registry layer (_registry_accessorsingleton +registrydiscovery wiring), theThemeManager+ThemeStatestate/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,mixinsThemeMixin,components,formsrenderer), the build/adapters/tooling (build_themes,shadcn,tailwind,inspector,checks,manifest,loaders,theme_packs,compat,contracts), theappsAppConfig,views,urls, and the leaf_config/_constants/_types/_builtin_presetsmodules. Annotated with real types (params + returns — notAnycosmetics), using thecast(str, mark_safe(html))boundary pattern for the theme-component renderers (django'ssafestringis unstubbed, somark_safereturnsAny;SafeStringitself resolves toAnywithout django-stubs, so astrcast is the honest no-Any-leak shape).mypy python/djuststays GREEN (822 files) with all 39 strict and the rest lenient; gate-off-verified (#1468) — a wrong-typedintreturn inmanager.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 defensiveif self._theme_manager is None: returnguards in the fourThemeMixinevent handlers (no-ops on the real post-mount path, matching the existing_setup_theme_contextguard). Full suite 8604 passed / 0 failed; 1863 theming tests pass (incl. the previously-flakytest_theme_tags_rust_engine_1721, green via #1929's fixture). Remaining theming/ for a continuation batch: thetheming/{templatetags,management,gallery}subpackages (the templatetag modules are the heaviest —theme_components~51 errors — so they're a separate batch). Seedocs/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]]withignore_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 aTYPE_CHECKING-guarded import so the optionalmcpdep is never imported at module load,_ensure_django() -> bool,main() -> None, and the observability-tool returns); the binary-WebSocket-frame upload system (uploads/__init__— theUploadWriterbase +BufferedUploadWriter+UploadConfig+UploadManager,uploads/resumable— the resumable chunk protocol,uploads/storage— the in-memory + RedisUploadStateStoreimpls,uploads/views— theUploadStatusViewHTTP endpoint withHttpRequest/JsonResponseannotations); 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 atests/dir, so the ratchet completes in one PR with no test sub-package to defer. Annotated with real types (params + returns — notAnycosmetics); the only narrow coded edges are:# type: ignore[override]on the legacywrite_chunk(self, chunk)adapters (BufferedUploadWriter,GCSMultipartWriter,AzureBlockBlobWriter) — the dropped trailingchunk_indexdefault is an INTENTIONAL, runtime-dispatched part of theUploadWritercontract (_writer_accepts_chunk_indexintrospects the signature; documented on the base method), andcast(...)narrows at the untyped boundaries (json.loadsinuploads/storage,boto3.generate_presigned_urlins3_presigned,requests.Response.text+session.session_keyinmcp/server/uploads/views). Twomcp/serverobservability-toolparamsdicts inferred homogeneous-then-mutated-with-the-other-type were annotateddict[str, object]. Third-partyrequests(consumed bymcp/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/djuststays GREEN (822 files); gate-off-verified (#1468) — a wrong-typed return injected intouploads/storage.deleteturns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed (413 upload/mcp-related tests pass). Seedocs/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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans every non-testadmin_ext/module: theDjustAdminSite(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+ theadmin_login_requiredwrapper and the_VIEW_REGISTRYplumbing), theAdminFormMixin(FK/M2M option loading, date/time field detection, readonly handling, real-time field validation), the bulk-action progress widget +@admin_action_with_progressdecorator, theAdminTailwindAdapteradmin CSS-framework adapter, theregister/action/displaydecorators, theDjustAdminConfigAppConfig, the autodiscover package__init__, and the admin template-tag helpers (get_item/get_field/concat/admin_url). Excludesadmin_ext/tests/, which stays on the lenient global default. Annotated with real types (params + returns — notAnycosmetics):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: Anyannotation-only attrs onAdminBaseMixin+AdminFormMixindocumenting the co-mixed-LiveViewcontract, plus a# type: ignore[misc]on the cooperativesuper().as_view()mirroringwizard.py); decorator function-attribute stamping (wrapper.short_description = ...) carries narrow# type: ignore[attr-defined]at the genuine dynamic edge.mypy python/djuststays 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_adapters→int) 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. Seedocs/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 entiremixins/subpackage — the LiveView mixin layer that composes the publicLiveViewclass — from the lenient mypy default to strict islands ([[tool.mypy.overrides]]withignore_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_work—start_async/defer/assign_async,waiters—wait_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 (context—get_context_data/_apply_context_processors/_deep_serialize_dict,jit—_jit_serialize_queryset/_jit_serialize_model/_get_template_content), the HTTPrequestmixin (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 largetemplaterendering mixin (render/render_full_template/render_with_diff/arender_chunks+ the HTML extraction/stripping helpers). Annotated with real types (params + returns — notAnycosmetics), using the establishedif TYPE_CHECKING:host-attribute-declaration pattern (mirroringstreaming.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-RustRustLiveView = None/extract_template_variables = Noneimport fallbacks; theevent_handlerdirect-file-import fallback shim; the dynamiccomponent_id/_auto_idattribute sets on theComponent | LiveComponentunion).rust_bridge/jitchange-detection is byte-identical — annotations are runtime no-ops; the_sync_state_to_rustchange-detection, the_framework_attrs-class filter conventions, and all id()/value comparison logic are UNTOUCHED (no comparison or filter expression was altered).mypy python/djuststays GREEN (822 files); gate-off-verified (#1468) — injecting a wrong-typed return into a now-strict mixin (template.get_template→int) turns the gate RED ([return-value]), reverting restores GREEN. Full suite 8604 passed / 0 failed. Themixins/ratchet completes in a single PR (nomixins/tests/sub-package exists to defer). Seedocs/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]]withignore_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 — everyNode.render(self, args/content, context) -> SafeString,do_*(parser, token) -> template.Node, and@register.simple_tag/inclusion_tagfunction, with inclusion_tags correctly typed-> dict[str, Any]since they return a context dict, not HTML), the per-widgetmixins/data_table(theDataTableMixin— its ~21on_table_*event handlers,handle_*override hooks,get_*/_apply_*pipeline, and the safe-arithmetic expression parser), the gallery LiveView surface (gallery/live_views—GalleryCategoryMixin+ 9 category views, with thetemplate_nameLiskov conflict resolved by aTYPE_CHECKING-onlyLiveViewbase alias;views,examples,registry,context_processors, and thecomponent_gallerymanagement command), the ~24 remainingcomponents/components/*widgets with untyped private-helper params (_render_node/_squarify/_compute_diff/_eval_expression/etc.), thelayout/tabs/data/pagination/ttyd/terminalleaves, and theui/*_simplestateless widgets +ui/dropdown(the over-narrow nav-item dict widened to the honestAnycontract per #1108; the optional-Rust import shims —from djust._rust import RustX/RustX = Nonefallbacks 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 themark_safe(...) -> SafeStringboundary pattern (noAnyleak).rust_handlersis deliberately left LENIENT — it is a genuinely-dynamic Rust-bridge registry whose 193 handlers parse untyped Rust-engine arg lists intodict[str, object](thekw.get() -> objectcascade), so strict typing surfaces 344 errors (203no-any-return+ 91call-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/djuststays GREEN (823 files) with all 68 strict andrust_handlerslenient; gate-off-verified (#1468) — a wrong-typed return inmixins/data_table([return-value]) and a dropped annotation intemplatetags/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 documentedrust_handlersexception remains lenient within components/). Seedocs/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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any). Spans the fullcomponents/components/widget catalog (146 modules — alert, badge, card, spinner, kanban-adjacent leaves, charts, etc.), theui/stateless widgets (8 — spinner, modal, alert, progress, badge, button, card, list_group), thedata//forms//layout//gallery//management//ttyd/leaf packages, the descriptor-based components (descriptors/*— the DEP-002Accordion/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 — notAnycosmetics): typed*_instancesclass vars (Optional[Dict[str, XState]]),instance_id: str/component_id: str/is_open: boolparams,get_*_ctx(...) -> Dict[str, Any]accessors, andrender() -> SafeString(mirroring themarkdown.pyisland —mark_safe(...)returnsAnyunder django's unstubbedsafestring, soSafeStringis the correct str-compatible annotation that cleanly absorbs theAnywithout 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/djuststays 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-errormark_safe/kw.get()-objecticeberg, a separate decision), the per-widgetmixins/data_table, the big templatetag modules (templatetags/djust_components/_advanced/_forms/_charts), thegallery/live_views/views/examplesLiveViews, the ~24components/components/*widgets with untyped private-helper params (_render_node/_squarify/_compute_diff/etc.), and the union-typedui/*_simplewidgets +ui/navbar_simple/modal/dropdown(over-narrow dict inference + the declared-but-unbuiltRustNavBar/Rust*fallback imports). Seedocs/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]]withignore_errors = false+disallow_untyped_defs+disallow_incomplete_defs+warn_return_any):components.__init__,components.apps,components.registry(theLiveComponentname registry),components.assigns,components.dependencies(theDependencyManagerCSS/JS asset registry),components.function_component(the@componentdecorator +{% 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(sharedformat_cell/interpolate_color/interpolate_color_gradient+CURRENCY_SYMBOLS),components.mixins.base(the per-component interactive mixin base —ComponentMixin+ theTypedStatedict subclass),components.templatetags._registry(the sharedtemplate.Library+ the security-sensitivesafe_urlscheme-validator +_resolve/_parse_kv_args), andcomponents.templatetags._dev_tools(the Terminal/MarkdownEditor/JsonViewer/LogViewer/FileTree dev-tool template tags). Annotated with real types (params + returns — notAnycosmetics); the only narrow coded# type: ignore[attr-defined]are at genuine dynamic edges (the@componentdecorator stamping_djust_*metadata onto a plainCallable; the per-invocation_slots/_childrenattached to aLiveComponentinstance for template render). Themark_safe-returns-Anyboundary is handled with typed-local narrowing (a small_safe(html: str) -> strwrapper in_dev_tools,str-typed locals elsewhere) — noAnyleak.mypy python/djuststays 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-> stris added — themark_safe/kw.get()-objecticeberg), the per-widgetmixins.data_table, and the big templatetag modules (djust_components/_advanced/_forms/_charts), plus the UI catalog (ui/,data/,forms/,gallery/, charts UI). Seedocs/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]]withignore_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(thelive_sessionURLconf walk + auth-filtered route-map emit),formsets,simple_live_view,testing(the publicLiveViewTestClient+SnapshotTestMixin+LiveViewSmokeTestfuzz/smoke harness),react,rust_components,frameworks(the CSS framework adapters),js(theJScommand-chain builder),wizard(WizardMixin),performance,profiler, andpresence(PresenceMixin+LiveCursorMixin). Annotated with real types (params + returns — notAnycosmetics); the only# type: ignore[misc]are at genuine mixinsuper()-delegation edges (wizardmount/get_context_data, which the LiveView MRO supplies at runtime).mypy python/djuststays 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). Seedocs/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/ViewRuntimedispatch spine (every mount + event flows through them) are now mypy strict islands ([[tool.mypy.overrides]]withignore_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 — notAnycosmetics):runtime.py(42 strict errors —ViewRuntimedispatch helpers,_build_request/_check_auth/_extract_*, the actor-mount path,_tenant_context),websocket.py(73 —LiveViewConsumerlifecycleconnect/disconnect/receive, thehandle_*verb handlers, the Channels event handlersserver_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 — theDjustSSE*Viewget/postHTTP handlers, the owner-binding helpers, the SSE event-stream async generator),streaming.py(6 —StreamingMixin, withTYPE_CHECKINGhost-class attribute declarations), andwebsocket_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-binaryreceive()text frame).mypy python/djuststays 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). Seedocs/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 thatpy.typedexposes to downstream consumers are now mypy strict islands ([[tool.mypy.overrides]]withignore_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_viewdecorator + private-state helpers) and its PEP 561 stublive_view.pyi;components/base.py(theComponent+LiveComponentpublic bases — descriptor protocol, render waterfall, event-handler factory);decorators.py(@event_handler,@action,@server_function,@reactive,@state,@computed,@optimistic,@background+ their nested wrappers/descriptors); andforms.py(FormMixin+LiveViewForm).mypy python/djuststays 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). Seedocs/adr/023-incremental-type-enforcement.md. -
Enforced incremental type-checking — a mypy merge gate + strict islands + the
_rust.pyiboundary (ADR-023). djust shipspy.typed(PEP 561 — downstream consumers type-check against djust's hints), andpyproject.tomldeclared a strict[tool.mypy]config — but mypy was invoked nowhere (CI / Makefile / pre-commit), so the strict config was dead andmypy python/djustreported 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]]withignore_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), plusrate_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-errormypystep in thepython-testsCI job (a new MERGE GATE — #1236 governance — wired into thetest-summaryAND-condition; ships gating because it is green by construction, per #1534), amake typechecktarget (inmake check), and a scoped pre-commit hook onpython/djust/**.py{,i}changes. The_rust.pyistub'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) sincepy.typedexposes it. Seedocs/adr/023-incremental-type-enforcement.md.
Changed
-
CI: CodeQL config excludes
py/ineffectual-statement(false-positive noise from the ADR-023TYPE_CHECKINGstub idiom). The strict-mypy ratchet addedif 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'spy/ineffectual-statementflags 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'squery-filtersso it doesn't recur as the type-checking blocks grow. Also converted acast("Any", …)string forward-ref to a directcast(Any, …)incomponents/rust_handlers.pyso CodeQL sees theAnyimport 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.gotoflake on the BLOCKING browser-smoke gate (#1943). The blocking browser-smoke gate (#1869) intermittently red-barred unrelated PRs withPage.goto: Timeout 30000ms exceedednavigating to/demos/browser-smoke/: on a cold-cache run that compiles thedjust_componentsRust crate from scratch, the demo uvicorn server wasn't ready within the fixed 30spage.gotodeadline (it cleared on a warm-cache re-run — not a real break). The.github/actions/djust-playwright-serverreadiness 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/, usingcurl -fsSso a half-initialized app's 5xx does NOT count as ready — this warms the route (first-hit lazy import/compilation) DURING the bounded loop sopage.gotolands on an already-warm route instead of racing its own deadline.tests/playwright/test_browser_smoke.py'spage.gotoalso 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.logdump) 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>) — CodeQLpy/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</tagfollowed 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). NewTestEndTagWhitespacePreservation(whitespace + newline + bogus-attribute cases, gate-off verified) intest_strip_whitespace.py. -
_run_async_workno longer writes against a stale view on disconnect/re-mount mid-await (#1940).LiveViewConsumer._run_async_workruns as a detachedensure_futuretask that capturesview = self.view_instancebefore its firstawait(the background callback). If adisconnect(which nullsview_instance) or alive_redirect/ re-mount (which reassignsview_instanceto a NEW view) interleaved during that await window, the completed task ran itshandle_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_asyncruns 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 inTestRunAsyncWorkTeardown(python/djust/tests/test_run_async_work_teardown_1940.py), gate-off verified. -
TutorialMixinnow initializes its four internal tutorial-signal attrs in__init__, not thetutorial_total_stepssetter (#1952)._tutorial_active_target,_tutorial_active_class,_tutorial_skip_signal, and_tutorial_cancel_signalwere previously initialized inside thetutorial_total_stepsSETTER, so aTutorialMixinview that never settutorial_total_steps(or read those attrs before the setter ran) hitAttributeError— e.g._cleanup_active_step()(called fromstart_tutorial'sfinallyblock) reads_tutorial_active_target/_class, andskip_tutorial/cancel_tutorialread the skip/cancel signals once running. The four attrs now default toNonein__init__(placed alongside the existing_tutorial_running/_tutorial_current_step/_tutorial_total_stepsinits); 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 inTestSignalAttrsInitializedInInit(read the four signals without invoking the setter; pre-fix raisedAttributeError). -
ComponentMixin.update_componentno longer raisesAttributeErroron aLiveComponent(#1947).update_component(component_id, **props)callscomponent.update(**props), butLiveComponent(a subclass ofContextProviderMixin, NOTComponent) had noupdate()method at runtime, so anyLiveComponentthat did not define its ownupdate()raisedAttributeErroron that path (the latent bug annotated with a# type: ignore[attr-defined]in ADR-023 M4c, part 1).LiveComponentnow has a baseupdate(**kwargs)that sets each prop as an instance attribute (mirroring the Python/hybrid path ofComponent.update) and returnsselffor chaining — the samecomponent.update(**props)API the component docs already document. Subclassupdate()overrides still take precedence. The stale# type: ignore[attr-defined]at the call site is removed. Regression coverage inTestUpdateComponentNoUpdateOverride(tests/unit/test_component_parent_communication.py): bare-LiveComponent update throughupdate_component, base-update()chaining returns self, subclass-override-still-wins. -
Dev-env: detect + recover the
core.bare = trueshared-config corruption that breaks worktree + main checkout (#1938). A linkedgit worktreeshares one.git/configwith the main checkout; if anything flipscore.baretotruethere (a build/PyO3-repoint step runninggit config core.bare true, an IDE/GitKraken integration, or a stray manual command — the #1804/#300 pattern),git status/git pushbreak in BOTH trees (every tracked file shows as deleted). An exhaustive audit confirmed no djust pre-push hook, test, or script writescore.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'sgit initfixtures), and all three were verified empirically to leavecore.bareunchanged — so the corruption is external, not a framework bug. Newscripts/check-shared-git-config.shreadscore.barefrom the SHARED config (resolved via--git-common-dir, works from any worktree), reports a leak (exit 1), and with--fixperforms the documented recovery (core.bare false); it NEVER writescore.bare true. The worktree-subagent mitigation (push--no-verify; CI is the authoritative gate) plus the detector are documented in CONTRIBUTING.md "Working in agit worktree". Tested by 5 cases intests/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_iterwas annotatedAsyncIterator[str]but yields theChunkEmitter'sbyteschunks (the emitterencode("utf-8")s every chunk before queueing, andStreamingHttpResponseis fed bytes) — the strict typing ofhttp_streaming.ChunkEmitter._aiter_impl() -> AsyncIterator[bytes]exposed the mismatch in the (already-strict M4c)mixins/request.py; corrected toAsyncIterator[bytes]. Other gaps (annotation-only, no behavior change):checks/security.pycheck_security's S002@csrf_exemptscan readnode.body[0].value.value(anast.Constant.value, astr | bytes | int | …union) and called.lower()on it — guarded withisinstance(doc, str)so a non-str first-statement constant can'tAttributeError(it never matched"csrf"anyway);_decorator_callable_nametypedOptional[str]so the_is_permission_required_decoratorcomparison stops leakingAny.auth/core.pycheck_view_auth'slogin_url(agetattr(...) or getattr(...)over unstubbed Django)casttostrfor the_check_django_access_mixins(login_url: str)contract;check_redis(djust_doctor) returnsOptional[_CheckResult](it returnsNoneto skip the non-Redis path). Plusbool(...)/str(...)/cast(...)boundary narrowing at the Django-untyped surface (user.has_perms(...),apps.is_installed(...),self.style.SUCCESS(...),json.loads(...),template.render(...),click.prompt(...)) anddict[str, Any]/list[CheckMessage]var annotations where mixed-type literals were inferred too narrowly.cleanup_liveview_sessionsimports the session helpers from their canonical source (djust.session_utils) instead of thelive_viewre-export so the strict island resolves them (equivalently exported vialive_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.packwas annotatedstrwith aNonedefault (a dataclass field lying about nullability —get_state()returnspack=Nonewhen no pack is configured), which also made theThemeState(pack=pack)construction an[arg-type]error againststr | None; corrected tostr | None = None.theming/_registry_accessor.py: theThemeRegistrysingleton's_presets/_themes/_packs/_manifests/_discoveredwere assigned only through a localinstin__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.packwereDesignSystem | None/ThemePack | None(theget_design_system/get_theme_packreturn type) but__init__raises when None, so every laterself.ds.typography/self.pack.icon_styleaccess was aunion-attrerror (14 in pack, 8 in theme) — narrowed by assigning the post-raisenon-None value to aself.ds: DesignSystem/self.pack: ThemePackannotated attr.theming/manager.py: the twoCompleteThemeCSSGeneratorreassignments (ingenerate_critical/deferred_css_for_state) collided with the innerThemePackCSSGeneratorgenvar's inferred type ([assignment]+ a phantom[attr-defined]ongenerate_critical_css) — renamed the inner varpack_gen.theming/palette.py:s_h/a_hmixedint(fromhex_to_hsl) andfloat(fromparams[hue_offset] % 360, where_MODE_PARAMSis inferreddict[str, float]because it mixessat_scale=0.85with integer hue offsets) — wrapped the (always-integer) hue offsets inint(...)to keeps_h/a_hint(int(180) % 360is identical).theming/build_themes.py:build_alldeclared-> Dict[str, str]butartifacts["individual_themes"]is alist[str]— the return type lied about the heterogeneous shape; corrected toDict[str, Any](+ themanifest/artifactslocals annotated).theming/accessibility.py: afloat ** floatreturnedAny([no-any-return]) and abool-orchain over unstubbed-attr comparisons returnedAny— wrapped infloat(...)/bool(...).theming/mixins.py: the conditional-importevent_handlerfallback was an unguarded[no-redef](narrow# type: ignore[no-redef]),_theme_managerwasThemeManagerwith aNonedefault (corrected toThemeManager | None), and the four event handlers gainedif self._theme_manager is None: returnguards so the_theme_manager.set_mode(...)accesses type-check (no-ops post-mount, matching_setup_theme_context's existing guard). Plus severalmark_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_passwordhad implicit-Optional defaults (field: str = None) that PEP 484 prohibits — corrected toOptional[str]; andModelCreateView.mountoverrodeModelDetailView.mount(self, request, object_id=None, ...)with a narrowermount(self, request, **kwargs)signature (an LSP[override]violation) — restored theobject_idparameter (still forced toNoneinternally, so the create view's "always start with no object" behavior is unchanged) so the override is contract-compatible.admin_ext/options.py: severalwarn_return_anyleaks at the Django-untyped boundary narrowed at the return site —_widget_has_permissionreturnsbool(user.has_perms(...)),get_formpinsmodelform_factory(...)to a typed local, andget_field_display_namewraps theverbose_name/short_descriptionreads instr(...).admin_ext/plugins.py:NavItem.has_permission/AdminWidget.has_permission/AdminWidget.rendersimilarly narrowed (bool(request.user.has_perm(...)),str(render_to_string(...))).admin_ext/__init__.pyautodiscoverandadmin_ext/apps.pyDjustAdminConfig.readygained explicit-> Nonereturns. -
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.pyComponentMixin.update_componentcallscomponent.update(**props)afterisinstance(component, LiveComponent), butLiveComponent(which subclassesContextProviderMixin, NOTComponent) has noupdatemethod at runtime — confirmed via the live MRO (Component.updateexists;LiveComponent.updatedoes not). Soupdate_component()would raiseAttributeErrorif ever invoked with aLiveComponent. The strict flip carries a narrow# type: ignore[attr-defined]with a comment at the call site; the fix (moveupdateontoLiveComponent, or change the routing) is left for a dedicated bugfix PR sincemixins/M4c(1) is annotation-only. Other gaps (annotation-only, no behavior change):mixins/page_metadata.py_pending_page_metadata/_drain_page_metadatawereList[Dict](incomplete generic) →List[Dict[str, str]].mixins/model_binding.pyallowed_model_fieldsclass attr was inferredNone(from= None) → annotatedOptional[List[str]](the true subclass-override contract);_dj_model_fieldsbarefrozenset→frozenset[str].mixins/rust_bridge.pyrendered_context = {}was inferredDict[str, dict[str, Any]]from its first (dict-valued) assignment, breaking later primitive/str assignments → annotatedDict[str, Any](no logic change; the change-detection path is byte-identical).mixins/template.pypos = open_pos + 4mixed thefloat("inf")sentinel into anintaccumulator → narrowedint(open_pos)in the branch whereopen_pos < close_posguarantees a real int;_current_html_size/_previous_html_sizedeclaredOptional[int]to match thegetattr(..., None)first-render seed.mixins/jit.py_variable_extraction_cachewasDict[str, dict]but storesOptional[dict]→Dict[str, Optional[dict]]; theif not extract_template_variablestruthy-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 builtinanyfunction used as a type — a typo) corrected toDict[str, Any].components/ui/modal_simple.py:Modal._render_customreadself.showbut__init__never assigned it (theshow=kwarg was passed tosuper().__init__but not re-set as an instance attr like its siblingsbody/title/etc.) —[attr-defined]against theRustModal-instance path; addedself.show = showto 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.templatereads were typedOptional[str](the baseComponent.template: Optional[str]class attr) while the subclass always sets astr— narrowed via atemplate = self.template or ""local at the top of_render_custom.components/ui/navbar_simple.py: the nav-itemsparam was annotatedList[Dict[str, Union[str, bool, List[...]]]]which mis-typed the nested-dropdownaccess as non-iterable (union-attron.get/__iter__) — widened toList[Dict[str, Any]](the honest contract for heterogeneous dynamically-accessed dicts, #1108). Float/int local-init mismatches in chart/heatmap/pivot renderers (total = 0→0.0,y = ...→y: float = ...,row_total/col_totals/grand_total→ float) where a numeric accumulator was seededintthen+='d a float (output via:.1f/_format_valis identical).components/gallery/registry.py:cat = info.get("category", "misc")wasobject-typed (from the heterogeneous EXAMPLES literal) socat.title()wasattr-defined/call-overload— coercedcat = str(...)(category is always a str). Numerous list/dict locals across data_table/templatetags annotated to fix mixed-elementvar-annotated(e.g.pages: listmixing 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 nestedState.active) was annotatedstr, but inmultiple=Truemode it holds a list of open item ids — soactives.remove(value)/actives.append(value)/state.active = [value]were[attr-defined]/[assignment]errors against the declaredstr. Corrected toUnion[str, List[str]](the true runtime contract — single id when single, list when multiple), narrowing the list branch withcast(List[str], inst.active)(mixin, guarded byinst.multiple) / the existingisinstance(actives, list)(descriptor). The 8 deprecated state mixins (tooltip/tabs/sheet/modal/dropdown/collapsible/carousel/accordion) hadcomponent_id = self._resolve_component_id(component_id)reassign anOptional[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 theinst is Noneguard). Typed all*_instancesclass vars (Optional[Dict[str, XState]]) and the descriptor_handle_event(self, state: "State", ...)params (the nested-State-subclass forward-ref, not the baseTypedState, sostate.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_toastcallsself.push_event(...), a method supplied by the hostLiveView(viaPushEventsMixin) and absent from the standalone mixin — mypy flagged[attr-defined]; declared the cooperating method underif TYPE_CHECKING:(the canonical djust mixin pattern, mirrorsstreaming.py), no runtime change.components/function_component.py:{% call %}dispatch setinstance._slots/instance._childrenon aLiveComponent(per-invocation template-render attrs, distinct from the class-levelslotsdeclaration list) — narrow# type: ignore[attr-defined]with an explanatory comment; the@componentdecorator's_djust_*metadata stamps on a plainCallablelikewise narrowed.components/presets.py:_BUTTON_PRESETS(heterogeneousstr/boolvalues) was inferreddict[str, object], making the built-inregister_preset(...)registration loop an[arg-type]error — annotatedDict[str, Dict[str, Any]].components/mixins/base.py:TypedState.__init__calleddefault.fget(self)whereproperty.fgetisOptional— added thefget is not Noneguard (behavior-preserving for every real_make_property-built property).components/utils.py+components/icons.py+components/suspense.py+components/templatetags/_registry.py: severalAny-leaks at Django boundaries (col.get(...)/value.strftime(...)/mark_safe(...)/conditional_escape(...)/render_to_string(...)) narrowed tostrat the boundary sowarn_return_anyis satisfied without anAnyescape. -
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_moduleswas annotatedDict[str, str]but everyregister()stores a nested{"module": ..., "export": ...}dict — the field annotation contradicted the (correct) return types ofget_module_info()/get_all_modules(); corrected toDict[str, Dict[str, str]].presence.py:broadcast_to_presence(event, payload: Dict[str, Any] = None)declared a non-Optionalparam with aNonedefault (the body already coalescespayload = {}) — corrected toOptional[Dict[str, Any]] = None.testing.py:assert_routed_views_allowedimported_routed_liveview_classesfromdjust.checks, where it is not re-exported (it lives indjust.checks.components) — corrected to import from the defining submodule (verified importable).performance.py:PerformanceTracker.root_node/current_nodewere inferredNone-only from__init__then reassignedTimingNode— declaredOptional[TimingNode], and the_find_parent_nodecall now guardsroot_node(was guarding onlycurrent_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._requestwas assigned (DjustSSEStreamView.get) and read (runtime.SSESessionTransport.build_request) but never declared in__init__— added theOptional[Any]declaration alongside_event_request.runtime.py:_instantiate_error_framewas first-assignedNonethen a dict (mypy inferredNone-only, so the dict assignments were errors) — declaredOptional[Dict[str, Any]]in__init__;_instantiate_viewcalledOptional[type]()("None not callable") — added theViewResolution.__bool__-impliedview_class is Noneguard; the dormant actor-mount path calledOptional[create_session_actor]— added the actor-availability guard.websocket.py:_recovery_htmlwasstr-typed from its first assignment but cleared toNoneon one-time use — annotatedOptional[str].live_view.pyi: the M2-island stub omitted the module-level_FRAMEWORK_INTERNAL_ATTRSthatwebsocket._snapshot_assignsimports — added it (a stub-completeness gap the M3 flip surfaced because websocket now resolves the import against the strict stub). SeveralAny-leaks at Django/PyO3 boundaries narrowed at the boundary (bool()/str()/int()wraps onvalidate_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_ATTRIBUTESwas annotatedSet[str](mutable) but holds afrozenset— the annotation lied about mutability for a membership-only, never-mutated security denylist; corrected tofrozenset[str], matching the immutable-denylist intent.security/log_sanitizer.py:sanitize_dict_for_log'sresultdict holds heterogeneous values (redacted strings, nested sanitized dicts, sanitized item lists) under an inferreddict[str, str]— annotateddict[str, Any].security/state_snapshot.py(sign_snapshot) andpermissions.py(dump_starter_document):[no-any-return]from untyped-dependency calls (TimestampSigner.sign,yaml.safe_dump) narrowed tostrat the boundary. Plus annotation gaps closed inrate_limit,_context_provider,schema,permissions, andtest_isolation(missing return/param annotations +var-annotatedhints). 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_handlerbare 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@overloadso both forms type correctly for downstream consumers.live_view._is_serializable:_non_serializablewas 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 — annotatedtuple[type, ...].live_view.pyi: thestreamstub was missing thelimitparam present onStreamsMixin.stream(stub-vs-source signature drift, the #1646 class) — added.mixins/handlers.py:_handler_metadatahad no base annotation, so its inferred non-optionaldictconflicted withLiveView.__init__'sNoneinit — annotatedOptional[Dict[str, Dict[str, Any]]]to match the runtime contract (theis not Nonecache 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_redirectto a non-LiveView path now falls back to a full-page navigation instead of stranding the page (#1934). Withauto_navigatedefaulting ON in v1.1, alive_redirectwhose target is a plain Django view (e.g. aTemplateView) 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 inhandleLiveRedirect(python/djust/static/djust/src/18-navigation.js): (1) thepushStatefired 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 usedresolveViewPath(), 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-viewlive_redirectto 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_urlguard also returnsNonefor a non-LiveView URL and keeps the stale client-supplied view, so the client must make the full-nav decision. Fix: a new STRICTresolveLiveViewPath()(route map ONLY, no container fallback) drives the cross-view decision; thepushState+ 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 throughwindow.djust.safeNavigationTarget(mirroring the existing cross-origin branch, with thesafeNavigationTargetopen-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 indescribe('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, andLiveView 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_workunder parallel-n autoby 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 viastart_async()inmount(), then asserts the callback ran (view.value == 42). The runtime dispatches that callback FIRE-AND-FORGET viaasyncio.ensure_future(self._execute_async_task(...))insideViewRuntime._dispatch_async_work(python/djust/runtime.py:4444), and_execute_async_taskITSELF awaits async_to_async(callback)thread-pool round-trip before settingvalue=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 whilevaluewas 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 (inpython/djust/tests/test_transport_behavioral_parity.py, test-only — production unchanged): wrap the runtime'sasyncio.ensure_futureseam duringdispatch_mountto capture the EXACT_execute_async_tasktask handle it spawns (the_flush_push_eventsfire-and-forget send, which uses the same primitive, is excluded by coroutine name so the gate-off stays sharp), thenawait 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 indispatch_mountspawns no_execute_async_task→assert async_work_tasksfails (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 intest_mount_dispatches_async_work. -
De-flaked the six rate-limit burst-exhaustion tests under
-n autoby OWNING THE CLOCK —test_ping_flood_triggers_disconnectno longer flakes on a wall-clock token refill (#1930).TestRateLimiter+TestGlobalRateLimit(tests/unit/test_event_security.py) build test-localTokenBucket/ConnectionRateLimiterinstances and assert burst exhaustion (e.g.rate=100, burst=2→ the 3rdcheck()must beFalse).TokenBucketreadtime.monotonic()directly, so under CPU-saturated parallelmake testreal wall-clock elapsed betweenconsume()calls and the refill mathtokens + elapsed * rate(rate=100 = 1 token / 10ms) added a token back — flipping a "burst exhausted → False" assertion non-deterministically toTrue. 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.monotonicmodule-level seam inpython/djust/rate_limit.py(production behavior identical; one indirection) routesTokenBucket.__init__+consume()through a patchable name WITHOUT patching the globaltimemodule; aFakeClock+frozen_clockpytest fixture monkeypatchesdjust.rate_limit._monotonicso the five burst-exhaustion tests run on a FROZEN clock (elapsed == 0→ no refill → deterministic), andtest_token_bucket_refillsreplacestime.sleep(0.05)withfrozen_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 checkFalse → 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 refilladvance()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 inTestRateLimiterandTestGlobalRateLimit.
[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 fordjust-mobile-togaconsumers 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_templatewires the variant resolver (LVN-II PR-4; ADR-019; #1578). Per-rendererresolve_template(base)delegates totemplate_resolver.resolve_variantusing the instance'soutput_format. TheNotImplementedErrorfromrender_with_diffnow 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 realPatchstreams 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.pywithvariant_name(base, output_format)andresolve_variant(base, output_format). Convention:foo.html→foo.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 intoNativeRenderer.render_with_diff. 6 new tests. -
NativeRendererscaffold +swiftui/composeregistry entries (LVN-II PR-2; ADR-019; #1578). Newpython/djust/renderers/native.pyintroducesNativeRendererwithSwiftUIRenderer/ComposeRenderersubclasses. Scaffold conforms to theRendererProtocol (output_formatper platform) but raisesNotImplementedErrorfromrender_with_diff— the actual widget-tree walker ships in LVN-II PR-3.RENDERERSregistry now resolves?platform=swiftuitoSwiftUIRendererand?platform=composetoComposeRenderer; this routes native handshakes to a defined error today rather than silent HTML fallback (which would mask client-side misconfigs). 9 new tests intest_native_renderer_scaffold.py. Existing handshake test intest_renderer_handshake.pyupdated to reflect thatswiftuiis now registered. -
Native widget vocabulary frozen at v1 (LVN-II PR-1; ADR-019; #1578). New
python/djust/renderers/widgets.pyexposesWIDGET_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), andis_widget_tag(tag). Newdocs/native-widget-vocabulary.mdis 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 byNativeRenderer(LVN-II PR-2) and mirrored indjust-native-ios/djust-native-android(LVN-III #1579 / LVN-IV #1580). 8 new pinning tests. -
LiveViewConsumerhandshake selects renderer via?platform=(LVN-I PR-3; ADR-019; #1577). Completes the LVN-I track (Protocol + ViewRuntime field + handshake wiring). NewRENDERERSregistry inpython/djust/renderers/__init__.pymaps"html"→HtmlRenderer;get_renderer_factory(platform)resolves a factory by?platform=value (returnsNonefor missing/unknown, so a typo never breaks a session — falls through to HtmlRenderer default at dispatch).LiveViewConsumer._get_runtimeparses?platform=fromscope["query_string"](ASGI bytes), resolves via the registry, and passesrenderer_factorytoViewRuntimeconstruction. Browser today sends no?platform=→ factory isNone→ byte-identical render. LVN-II (#1578) will registerswiftui+composeand 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 arenderer_factorykwarg (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. DefaultNonefor full back-compat — existing call sites inpython/djust/websocket.py:4248andpython/djust/sse.py:110are unchanged. The dispatch site (TemplateMixin.render_with_diff:942) still constructsHtmlRenderer(self)inline; PR-3 will route through the runtime's factory. New test filepython/djust/tests/test_runtime_renderer_param.py(3 cases: default-None, factory-stored, existing-callers-unchanged). -
djust.rendererspackage — pluggableRendererProtocol + defaultHtmlRenderer(LVN-I PR-1; ADR-019; #1577). Foundation iteration of the LiveView Native track. Introduces a structurally narrowRendererProtocol (@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).HtmlRendererwraps the existing Django-template + Rust VDOM pipeline; behavior is byte-identical to the pre-refactor inline call.TemplateMixin.render_with_diffatpython/djust/mixins/template.py:942now dispatches throughHtmlRenderer(self).render_with_diff(...)instead of inliningself._rust_view.render_with_diff(). What's explicitly NOT in this PR:ViewRuntimeplumbing (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,HtmlRendererconformance via@runtime_checkableisinstance, delegation to_rust_view, and the dispatch gate that the mixin routes throughHtmlRendererinstead 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-
WebsocketCommunicatorflip 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.pycharacterizes the six mount behaviors the flip must preserve, driving each against the CURRENT bespokehandle_mountover a real channelsWebsocketCommunicator(each passes now + must stay green through the flip = the parity proof, #1466/#1780/#1468): actor MOUNT (ause_actorsview renders an actor-backed mount frame, NOT the SSE refusal — Finding D),sticky_hold-before-mount-frame ORDERING vialive_redirect_mount(Finding B), Channelsgroup_addserver-push reachability (a broadcast to the mounted view's group reaches the session), periodic tick started at mount (asource="tick"frame arrives with no client event),optimistic_rules+upload_configson 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 nullsself.view_instancebefore re-mounting, and a naive flip that forgets to also resetruntime.view_instancewould silently no-op the re-mount sincedispatch_mountearly-returns whenview_instance is not None). Each asserts intermediate state + has a gate-off/contrast sibling.python/djust/tests/test_transport_behavioral_parity.pygrows 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_pendingsource pin) and extends_WS_ONLY_MARKERSwith 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"}) andhandle_mount/handle_mount_batchare 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 toTrue(it was opt-in /Falsethrough 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. Nativedj-navigateis now djust's canonical SPA-navigation model (no manuallive_session+ route-map wiring needed). Opt out withLIVEVIEW_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-blockingplaywright-testsleg into its OWN dedicatedbrowser-smokeCI job (nocontinue-on-error) and wired into thetest-summaryaggregate gate's AND-condition, so a re-introduced runtime break of either class now red-bars the PR (mirrors thedemo-checksblocking-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-blockingplaywright-testsleg — 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 viawindow.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"inRUNTIME_OWNED_VERBS, soreceive()routes every WS mount frame through the singledispatch_message→dispatch_mountchokepoint, and the ~870-line bespokehandle_mountbody is DELETED — reduced to a THIN SHIM overdispatch_mount(mirroring the event flip #1907 andhandle_url_change). Phases 3.0-3.3a had already growndispatch_mountinto a functional superset (F22 view resolver,run_pre_mount_authpre-mount auth+tenant via_check_auth,on_mounthooks, session + signed-snapshot state restore, post-mount object-permission,handle_params, actor mount, no-arm mount wire version, thesticky_holdpre-mount frame, the auth verdict→close finalize, the 2-queue mount-time drain) viaWSConsumerTransporthooks. This PR is the atomic flip with the three load-bearing findings wired: (A) idempotency — the shim, the_dispatch_runtime_ownedmount arm,disconnect, and thelive_redirectteardown all nullruntime.view_instanceBEFORE dispatch, so a reconnect /live_redirectre-mount is never silently no-op'd bydispatch_mount'sif view_instance is not Noneearly-return (the #560-class landmine); (B) ownership inverts — mount CREATES the view (runtime→consumer), so the shim reads backself.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_notifygroup_add, the periodictick_intervaltask, theuse_actorsflag, the real-scope_websocket_path/_websocket_query_stringstamps, the_sticky_auto_reattachedreset) is folded into the now-LIVE WSon_view_mountedtransport hook — madeasynctoawaitgroup_add— Finding B residual; (C) mount wire version via thenext_mount_versionhook (the no-arm consumer counter). The object-perm denial now closes the socket viafinalize_mount_auth(Finding E — the bespoke unconditionalclose(4403)had no runtime equivalent). Amount_batchbug the flip surfaced is also fixed:ViewRuntime._instantiate_viewfire-and-forgot its error frame viaasyncio.ensure_future, leaking a FAILED view's error into the NEXT survivor's collector (flipping a survivor tofailed[]); it now stashes the frame anddispatch_mountawait-sends it inside the correct_mount_onewindow.handle_mount_batch/_mount_onestay WS-only (the collector contract is unchanged;finalize_mount_authstill gates the redirect-verdict close onnot _mounting_in_batchper #291/#1780). Boundary pins updated to the post-flip reality: theRUNTIME_OWNED_VERBScontract, the Concern-4 mount-orchestration count-canary (run_pre_mount_auth/ object-perm /validated_host_from_scopeconverged ontoruntime.py),_WS_ONLY_MARKERS(group_add/channel_layer/tick_intervalmoved to the runtime hook), and thehandle_mountsource-grep pins (snapshot sign/unsign, skip-html,_ensure_tenant-before-restore,has_ids, mount-url validation, next-version) moved todispatch_mount; the fake consumers intest_sw_advanced.py/test_sw_advanced_flow.pygained a permissive_rate_limiterso they drive the runtime path. Gate-off-verified (#1468): neutering the Finding-A null makes thelive_redirectre-mount net (test_ws_mount_flip_parity_1911.py::TestLiveRedirectRemountIdempotency) RED; neutering theon_view_mountedfold makes thegroup_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 WShandle_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_VERBSis UNCHANGED ({"url_change", "event"}),handle_mount/handle_mount_batchare UNTOUCHED (websocket.pyhas no diff) — but the dormant hooks are now called bydispatch_mountat their WS-faithful positions (read offhandle_mount): (1)on_view_instantiated(view)right after instantiation (WS stamps_ws_consumer/_push_events_flush_callback/ observabilityregister_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 WSuse_actorsview now RENDERS through the actor system at the render step (verbatimhandle_mountordering — after auth +mount()+handle_params, html sent without strip/extract,websocket.py:2691-2706); SSE keeps refusing (uses_actors_for_mount→ False, so the structureduse_actors is not supported over SSEenvelope 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 (WSconsumer._next_version()— establishes the baseline, does NOT armrequest_htmlrecovery so_recovery_htmlstaysNone; SSE returns the raw Rustrender_with_diff()version, IMPLEMENTED here — the 3.2 SSE placeholder raised). The signature is widened to(html, rust_version=1)mirroringnext_client_versionso 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 ARMINGnext_client_versionthe event path uses. (4)on_mount_render_ready(view, html)(Finding B residual) runs after render, before the mount frame (WS sticky preservation + thesticky_holdframe emitted BEFORE the mount frame; SSE returnshtmlunchanged). (5)finalize_mount_auth(view, verdict)(Finding E) on the three auth-block verdicts (_check_authpermission_denied + redirect;dispatch_mountrun_on_mount_hooksredirect) — the runtime already sent the verdict frame + clearedview_instance, so the hook adds ONLY the transport-levelclose(4403)(WS unconditional for permission-denial, gated onnot _mounting_in_batchfor 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_mountis now a clean superset (Findings A/B prep) — the idempotency guard +view_instanceownership are untouched; the residual delta for the 3.3b flip is the routing flip + the A/B shim (theruntime.view_instancereset + read-back) only. New cases inTestRuntimeBasicMountParity,TestRuntimeActorMountParity,TestRuntimeNoArmVersionWiring,TestRuntimeAuthBlockFinalize,TestRuntimeStateRestoreParity(python/djust/tests/test_runtime_mount_parity_1917.py) — THE key 3.3a gate: drivesdispatch_mountover a REALWSConsumerTransport(direct-call shim, NOT viaRUNTIME_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, thenext_mount_versionwiring off → the wrong version is stamped. The Phase-3.2 DORMANT pins inpython/djust/tests/test_transport_mount_hooks_1915.pyare INVERTED to load-bearing WIRED pins (each hook is now referenced indispatch_mount/ the auth helper; SSEnext_mount_versionreturnsrust_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 theTransportprotocol (behavior-preserving no-op / refuse defaults),WSConsumerTransport(the real WS impl, each encapsulating the verbatim bespokehandle_mountlogic for its cited site), andSSESessionTransport(no-op / raw / refuse), addressing ADR-022 Iter 3 Findings B/C/D/E: (1)on_view_instantiated(view)— WS stampsview._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 (thedispatch_mountrefusal stays). (3)next_mount_version(html)— WS returnsconsumer._next_version(), the NO-ARM counterhandle_mountuses (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 fromnext_client_version, which arms for render-SEND frames), so_recovery_htmlstaysNoneafter 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_idssurvivor scan +_register_childre-registration) + thesticky_holdframe emitted BEFORE the mount frame (websocket.py:2080-2082/2836-2903), returninghtmlunchanged; SSE: returnshtmlunchanged (Finding B residual). (5)finalize_mount_auth(view, verdict)— WS: the transport-level socketclose(4403)the bespoke auth-finalization performs (websocket.py:2337-2401), GATED onnot consumer._mounting_in_batchfor 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_mountdoes NOT call any of these yet (Phase 3.3a wires them in) and the WS bespokehandle_mount/handle_mount_batchkeep doing all of this inline (untouched until the Phase 3.3b flip);RUNTIME_OWNED_VERBS/ WS routing are UNTOUCHED;websocket.pyhas no production diff. New cases inpython/djust/tests/test_transport_mount_hooks_1915.py(Test...MockTransport unit tests per hook + real-WebsocketCommunicatortests exercising the WS impls in isolation against a genuinely-mounted consumer —uses_actors_for_mountTrue for ause_actorsview,next_mount_versionreturns the consumer counter WITHOUT arming recovery,finalize_mount_authdoes NOT close when_mounting_in_batch=True) + DORMANT pins (dispatch_mountdoesn't reference the hooks, still stamps the raw Rust version + still refuses actor mounts;handle_mountstill does the work inline). All gate-off-verified (#1468): arming recovery innext_mount_versionreds the 3 no-arm tests, removing thenot _mounting_in_batchgate reds the in-batch tests, no-op'ingon_view_instantiatedreds the stamp test. The anti-drift_WS_ONLY_MARKERSpin (test_transport_behavioral_parity.py) dropscreate_session_actor/_find_sticky_slot_ids/register_view(no longer WS-only — the dormant WS hooks now reference them inruntime.py), mirroring the Phase-3.1state_snapshot_signedmove. -
ViewRuntime.dispatch_mountgrew the transport-agnostic mount STATE-RESTORE +on_mounthooks WebSockethandle_mounthas, 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 onenable_state_snapshot(#1552) so default views are unaffected: (1)run_on_mount_hooks(websocket.py:2383-2401) runs the registeredon_mounthooks after the pre-mount auth sequence + beforemount(); a hook that returns a redirect URL emits anavigateframe, clears the unmounted view, and aborts — transport-agnostically (no socketclose(); that belongs to the Phase 3.2/3.3afinalize_mount_authhook, 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 ofmount(). (3) Thehas_prerendered→skip_html_for_resumeresume optimization Phase 3.0 wired (but left dormant) now ACTIVATES: a restore (session or signed-snapshot) sets the new_mounted_from_restoreframework flag, so a resuming client that already holds the DOM skips the redundant mount-HTML swap (theversionstill flows so patches stay in sync)._mounted_from_restoreis initialized inLiveView.__init__BEFORE the_framework_attrssnapshot (#1393) so it is reset on reconnect and never persisted. Blast radius: SSE mount (which usesdispatch_mount) + the runtime;websocket.pyhas no diff,RUNTIME_OWNED_VERBS/handle_mount/handle_mount_batchare unchanged. The anti-drift_WS_ONLY_MARKERSpin dropsstate_snapshot_signed(no longer WS-only — now on the runtime too) and thelive_view.pysetattr-whitelist line numbers shift +11. New cases inpython/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); anon_mountredirect emits anavigateframe + aborts (RED when the redirect handling is gated off). -
ViewRuntime.dispatch_mountgrew the transport-agnostic mount behaviors WebSockethandle_mounthas, 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 ofhandle_mountover 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_kwargsstash (#1895,websocket.py:2596, placed aftermount()+ object-perm, beforehandle_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_baselinepost-mount (websocket.py:2598-2603); (3)has_prerendered→skip_html_for_resumemachinery (websocket.py:2804-2816), dormant until Phase 3.1 wires session-restore (the_mounted_from_restoreflag defaultsFalse, so HTML is always sent today); (4)optimistic_rules(DEP-002) +upload_configson the mount frame (websocket.py:2823-2834, via a new runtime_extract_optimistic_rulesmirror); (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_pendingthe 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 intest_handle_mount_drains_queues.py. Blast radius: SSE mount (which usesdispatch_mount) + the runtime;websocket.pyhas no diff andRUNTIME_OWNED_VERBSis unchanged. Every grow has a gate-off witness intest_transport_behavioral_parity.py(7/7 verified RED). New cases inTestMountStashAndBaselines,TestMountAsyncAndPushDrain,TestMountFrameOptimisticAndUpload,TestMountFrameWireVersion. -
THE FLIP: every WebSocket event now routes through
ViewRuntime.dispatch_event— the bespoke_handle_event_inneris 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 toRUNTIME_OWNED_VERBS(now{"url_change", "event"}), soreceive()routes every WS event through the singleViewRuntime.dispatch_messagechokepoint;handle_eventbecomes a thin shim overruntime.dispatch_event(mirroringhandle_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 newTransporthooks (SSE no-op):on_render_emittedcarries the production-visible DJE-053 warning (#1079 — it MUST survive, and does) plus the_emit_full_html_updatesignal on the no-patch render branch, andon_handler_timingcarries therecord_handler_timingpercentile telemetry;cache_request_idwas 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_navigationis nowawait-ed (was fire-and-forget) and (2) the skip-render branch now calls_flush_all_pending, so alive_redirect()/ navigation command queued by a state-unchanging handler still emits itsnavigationframe within the event turn (WS parity); and (3) the runtime's_dispatch_event_rendernow records a time-travel snapshot witherror="permission_denied"/"validation_failed"on the security-rejected + validation-rejected early-return paths (record_event_startmoved BEFORE the security check) — the bespoke_handle_event_innerrecorded these for the debug panel, and the first flip pass dropped them for non-actor views (caught bytests/integration/test_time_travel_flow.py::test_permission_denied_view_handler_records_with_error). Boundary pins updated (RUNTIME_OWNED_VERBScontract, 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. NewTestResidualFoldObservability(DJE-053 +record_handler_timingsurvival, with reason/version gate-off siblings) and aWebsocketCommunicatorregression forstart_async/@backgroundstreaming itssource="async"result over the runtime async path. Gate-off (#1468): removing"event"fromRUNTIME_OWNED_VERBSmakes all 11test_ws_event_flip_parity_1896behaviors fail withUnknown message type: event(the bespokeelifis 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. -
ViewRuntimeasync-result frames now carrysource="async", reconciling them with the WebSocket_run_async_workframes; and the deaduse_binaryframing 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) asyncsource="async"reconcile —ViewRuntime._render_async_result(thestart_async/@backgroundcompletion render shared by the success + error paths) emittedpatch/html_updateframes with NOsourcetag, while the WS_run_async_worktags all four of its framessource="async"(websocket.py:1166/1186/1223/1238). The client usessourceto distinguish an out-of-band background-completion update from the in-turnsource="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 stampsource="async". LIVE for SSE +url_changeasync work (both use the runtime async dispatcher today); WS picks it up post-flip (Phase 2.3b). (2) binary-framing confirm —consumer.use_binaryis dead: initialized toFalseatwebsocket.py:580('MessagePack support TODO') and never setTrueanywhere in the package; the only honoring site is_send_update's binary branch (websocket.py:1391), whichWSConsumerTransport.senddoes NOT traverse (it callsconsumer.send_json, always JSON). DESCOPED (no new binary path invented) + PINNED so a future enable is a deliberate, tested change: a guard test assertsWSConsumerTransport.sendemits JSON viasend_json(matching live WS), plus a source-grep pin that no production module assignsuse_binary = True. No change toRUNTIME_OWNED_VERBS/ WS routing; WS_handle_event_inner's async/binary paths stay on the bespoke handler until 2.3b;websocket.pyhas no diff. New cases inTestAsyncSourceReconcile/TestBinaryFramingConfirm(python/djust/tests/test_runtime_reauth_async_1905.py): real-SSE end-to-end (astart_asynccompletion frame carriessource="async") + unit (both branches tagged) + the JSON-emit + source-grep pins, with a gate-off witness (#1468) — removing thesource="async"tag makes the SSE end-to-end + unit assertions RED.test_async_integration+test_sse_runtime_convergence_1887stay green. -
ViewRuntimegained 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 lackeddj_activitydeferral 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_innerdoes (websocket.py:3254-3273gate +4290-4294flush). Two parts: (1) Gate —ViewRuntime._dispatch_event_render(after embedded-child routing, before security validation) replicates the WS gate VERBATIM, reusing the SAME transport-agnosticActivityMixinview 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-blindActivityMixin._flush_deferred_activity_eventsas the_dispatch_single_eventprovider, somixins/activity.pyis UNCHANGED (the flush already accepts any object exposing that method). The newViewRuntime._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-enteringevent_context— it already runs inside the borrowed context (which on WS holds the consumer_render_lock; re-acquiring the non-reentrantasyncio.Lockwould deadlock, thewebsocket.py:1467contract). A denied queued event is re-validated and dropped (WS flush per-event parity). Live behavior: this goes LIVE for SSE events — they route throughdispatch_eventsince Iter 1 (#1887), so SSE events now respectdj_activitydeferral (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_innergate/flush stays until Phase 2.3b;RUNTIME_OWNED_VERBS/ WS routing are UNTOUCHED;websocket.pyhas no diff. New suitepython/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 WSdj_activitybehavior (tests/unit/test_activity.py), the #1896 parity net (bespoke path, unchanged), andtest_sse_runtime_convergence_1887stay green. -
ViewRuntimegained an actor-event transport hook (transport.uses_actors()+transport.dispatch_actor_event()) so ause_actorsview'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_eventhad NO actor branch, while theuse_actorsguard lived ONLY indispatch_mount(which refuses SSE outright). A WS view mounts in actor mode (use_actors=True+ a createdactor_handle); once Phase 2.3b routes WS events through the runtime, such a view's events would have hitdispatch_eventwith no actor branch and silently run the handler IN-PROCESS via the normal render path, desyncing the actor's server-side diff baseline. Two newTransporthooks close the gap: (1)uses_actors(view)—WSConsumerTransportreturnsconsumer.use_actors and consumer.actor_handle is not None(the exact precondition of the bespoke WS actor block,websocket.py:3282),SSESessionTransportreturnsFalse(SSE has no bidirectional actor channel anddispatch_mountrefusesuse_actorsmounts,runtime.py:602); (2)dispatch_actor_event(view, event_name, params, *, event_ref, cache_request_id)—WSConsumerTransportruns the bespoke WS actor block (websocket.py:3282-3379) VERBATIM against the consumer (time-travel record/push in afinally, the shared_validate_event_security+validate_handler_paramschecks,actor_handle.event(), patch/HTML framing stamped with the consumer-owned wire versionconsumer._next_version()— the actor's internalresult['version']is IGNORED for the wire, #1788 — error handling, and the v0.7.0 deferred-activity flush),SSESessionTransportraisesNotImplementedError(never called —uses_actorsisFalse). Wired into_dispatch_event_innerBEFOREevent_context(the actor block holds no render lock, matching WS), gated onuses_actors(view)AND the event NOT being routed to a sticky child — the WSnot is_embedded_child_targetmutual exclusion (websocket.py:3280-3282); per #1467 acomponent_idevent does NOT reassign the target view and the WS actor block has no component handling, so acomponent_idevent on ause_actorsview goes through the actor (parity), and only aview_idresolving to a DIFFERENT child excludes it (_event_routes_to_sticky_childpeeks atview_idWITHOUT consuming it, so the non-actor sticky-child routing still pops it). Zero live-behavior change:uses_actorsisFalsefor 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_inneractor block are UNTOUCHED (they stay until 2.3b);websocket.pyhas no diff. New direct-runtime suitepython/djust/tests/test_transport_actor_event_1901.py(12 cases) builds aWSConsumerTransportover a fake consumer withuse_actors=True+ a fakeactor_handleand assertsdispatch_eventroutes todispatch_actor_event(the actor's.event()is called + the framed result is sent via_send_updatewith the consumer-owned wire version, NOT the in-process handler),uses_actorsFalse for SSE + a WS consumer withoutactor_handle, aview_id-routed event skips the actor while aview_id-equals-top event still routes to it, and the SSEdispatch_actor_eventraises; gate-off verified (#1468) — forcinguses_actorsto always returnFalsemakes 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 #1899event_contextsuite stay green. -
ViewRuntimenow BORROWS the consumer's render-lock + origin-channel + observability scope for each event via a newtransport.event_context()hook, and the dead runtime-local_render_lockis deleted (#1899, ADR-022 Iter 2 Phase 2.3a). Foundational fold thedj_activityre-dispatcher + the 2.3b WS-event flip sit on. Two load-bearing flip-scope findings drove this: (1)ViewRuntime._render_lockwas 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_notifyrender 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-CMtransport.event_context(view)on theTransportprotocol + both adapters lets the runtime borrow the consumer's EXISTING lock:WSConsumerTransport.event_contexton enter mirrors_handle_event_innerverbatim —await consumer._render_lock.acquire()(the existing object, not a new one),_processing_user_event = True, set the #1677 origin-channel contextvar toconsumer.channel_name, start aPerformanceTracker+ the SQLcapture_for_eventscope (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_contextis 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_inneris extracted into_dispatch_event_renderand run insideasync 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_innerare UNTOUCHED, anddispatch_url_change/_dispatch_url_change_innerare 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_changeis unaffected. New direct-runtime suitepython/djust/tests/test_transport_event_context_1899.pyasserts the WS context borrows the consumer's EXISTING lock object (held inside, released after — incl. on exception),_processing_user_eventTrue-inside/False-after, origin token set+reset, tracker current-inside/cleared-after; the SSE context is a no-op;ViewRuntimeno 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-opevent_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.
ViewRuntimenow records + persists per-event state the way the bespoke WS_handle_event_innerdoes, so the Phase 2.3 final flip (routing WS events through the runtime) persists identically: (1) time-travel record —record_event_start/record_event_endwrap 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 afinallyso a raising/permission-denied handler still appears in the debug panel; (2) session state-save #1466 —ViewRuntime._persist_state_after_eventmirrors the WS save (private attrs first, then publicget_context_data(), then components), gated on top-level-view identity ANDenable_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 150msasyncio.wait_for(#1475); (3) sticky-child state-save ADR-018 —ViewRuntime._persist_sticky_child_after_eventpersists aview_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 hookon_event_recorded(view, snapshot)replaces the WS_maybe_push_tt_eventdirect send:WSConsumerTransportdelegates to the consumer's existing_maybe_push_tt_event(single-sourcing the DEBUG-gatedtime_travel_eventframe),SSESessionTransportno-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 inwebsocket.pyis UNTOUCHED (the #1466/#1552 grep-pins intest_ws_reconnect_state_1465.py:119/313/320stay green;eventstays out ofRUNTIME_OWNED_VERBS, the WS flip is Phase 2.3). New direct-runtime suitepython/djust/tests/test_runtime_state_save_tt_1894.py(12 cases) drivesruntime.dispatch_eventagainst a MockTransport; each subsystem has a reproduce-first + gate-off pair (#1468) — removing theenable_state_snapshotgate makes a default view wrongly persist (RED), neutering the time-travel record drops the snapshot + hook (RED), and disabling the hook dispatch makes theon_event_recordedassertion 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_idLiveComponent,view_idsticky-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_innernow routes embedded children before the single-view path, mirroring the bespoke WS_handle_event_innersubsystems the runtime previously lacked entirely: (1) aview_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 scopedembedded_update {view_id, html, event_name}frame — the client-suppliedview_idis never echoed into the user-facing error (sanitize_for_login the structuredextraonly, verbatim from WS); (2) acomponent_id-targeted event resolves a child LiveComponent via_components, validates the handler against the COMPONENT (not the parent), notifies the PARENT's waiters withcomponent_idinjected (ADR-002), and emits a parent-scoped full-HTMLcomponent_eventframe — 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-levelwebsocket.render_embedded_child_html, the WS_render_embedded_childis 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_innerrouting is untouched (WS events still flow through it;eventstays out ofRUNTIME_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 suitepython/djust/tests/test_runtime_child_routing_1892.pydrivesruntime.dispatch_eventagainst 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 —
refecho,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_innerhas but the runtime lacked: (1) the clientref(#560) is now echoed back on BOTH the noop and every update frame, coerced to int (type-confusion guard); (2) the noop frame carriessource="event"+event_nameand the update frames carrysource="event"for the client's #560 response-sequencing; (3) a handler that sets_force_full_htmlnow defeats the auto-skip and sends a fullhtml_update(patches discarded, flag consumed), mirroringwebsocket.py:4039-4040; (4)_notify_waiters(ADR-002 Phase 1b) runs after the handler sowait_for_eventfutures resolve on the SSE path too; (5) the #700 identity push-only auto-skip (theid()-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 consumers —websocket.pyis untouched (WS events still use_handle_event_inner;eventstays out ofRUNTIME_OWNED_VERBS, the WS flip is Phase 2.3) — and SSE consumers gain the #560ref/sourcefields. Each grow is reproduce-first + gate-off verified (#1468): new behavioral pins (TestEventSpineRefEcho,TestEventSpineForceFullHtml,TestEventSpineNotifyWaiters,TestEventSpineIdentityPushSkip) and a source-enumeration net (TestEventSpineEnumeration) inpython/djust/tests/test_transport_behavioral_parity.pyso a future drop re-forks RED; a real-SSE-transport end-to-end suite (TestSSEEventSpineParityinpython/djust/tests/test_sse_runtime_convergence_1887.py, driving the/message/endpoint which forwards the fullref-carrying envelope); and an extendedRUNTIME_OWNED_VERBScontract pin (TestRuntimeOwnedVerbsContract::test_event_spine_grown_but_event_not_yet_ws_ownedinpython/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 andViewRuntimepaths carry, i.e. a live instance of the #1646 parallel-path-drift class. Both now dispatch throughsession.runtime.dispatch_mount/dispatch_event— the SAME spine the SSE/message/endpoint and the WSurl_changeshim 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 streampatch/html_updateframes, object-permission denial still blocks the mount (now viadispatch_mount's Iter-0 check), andstart_async/@backgroundwork still streams its result (the runtime grew the async dispatcher SSE needs — this also fixes a latent legacy-SSE drop ofstart_asyncnamed-task work, since the legacy path only dispatched the never-set_async_pendingformat). SSE-specific behavior is preserved via two newSSESessionTransporthooks:build_request()(the runtime mounts against the real HTTP request, not a synthesized userless one) andon_view_mounted()(stamps_sse_session_id/_sse_session/session.view_instance). Nowebsocket.pychanges (WS convergence is Iter 2/3). New end-to-end integration suitepython/djust/tests/test_sse_runtime_convergence_1887.py(mount / event / object-perm /start_asyncvia 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 there.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'saddEventListener/ init never ran — with NO console error. This is why #1871'swindow.djust._runInsertedScriptsmount-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__smokeTabsWiredstayedundefineduntil 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 inTestStripCommentsAndWhitespace(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-blockingbrowser-smokeCI 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_authclosed the socket with code4403UNCONDITIONALLY on thepermission_deniedverdict, while gating the redirect verdicts (login-required /on_mountredirect) onnot mounting_in_batch(the #291/#1780 multiplexed-path rule). Inside amount_batchthe 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 bespokehandle_mountwhich also closed unconditionally). Thepermission_deniedclose is now gated onnot self.mounting_in_batchtoo, so all blocking mount-auth verdicts share one batch-aware close. No security loss: the denied view is NOT mounted regardless — the runtime sends theerror(permission_denied) frame and clearsview_instanceBEFOREfinalize_mount_authruns; only the transport-level socket close is suppressed in the batch case, so the denied view simply reports infailed[]exactly as the redirect case already reports innavigate[]. The denial holds; the siblings (which the client IS authorized for) are no longer dropped. A SINGLE (non-batch) denied mount STILL closes4403(mounting_in_batchisFalseoutside a batch). New cases intest_ws_auth_close_socket.py(realWebsocketCommunicator, 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) andtest_single_objperm_denied_mount_still_closes_socket(over-gating guard). Gate-off (#1468) verified: reinstating the unconditionalpermission_deniedclose makes the batched-denial test go RED (the ping openness probe receiveswebsocket.closeinstead ofpong); 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_updateattached three things a runtime-routed WS event (which sends viatransport.senddirectly) dropped — (1) the per-event_debugdebug-panel payload (_attach_debug_payload, DEBUG +_debug_panel_activegated) plus the top-leveltiming/performancefields (gated on_should_expose_timing()= DEBUG orDJUST_EXPOSE_TIMING); (2) theno_patchescontext_snapshotthe bespoke path passed to_emit_full_html_update; and (3) the cosmetic_current_event_name/_current_event_refconsumer attrs. A newTransport.on_event_frame(view, frame, *, event_name, event_ref)hook (SSE no-op) — called by_render_and_sendin-place just before everypatch/html_updateevent frame — attaches (1) via the consumer's existing_attach_debug_payload+_should_expose_timing(verbatim bespoke gate;performancefrom theevent_context-borrowedPerformanceTracker;timing.renderfrom a render-duration measured per event) and stamps (3);on_render_emittedgrew acontextparam so theno_patchesbranch threadsget_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_msmarker is always popped before send and never reaches the wire. (#1921) dead code: theLiveViewConsumer._extract_cache_config/_extract_optimistic_rulescopies had ZERO callers after the mount flip deleted thehandle_mountbody that called them (orphan-grep confirmed acrosspython/+tests/);ViewRuntimeowns the live copies the mount frame uses. Removed; the runtime docstrings' stale "Mirror ofLiveViewConsumer._extract_*" refs are corrected. No change toRUNTIME_OWNED_VERBS/ routing; SSE unaffected. New cases inTestResidualFoldObservability+TestDebugResidualOnEventFrame(python/djust/tests/test_ws_event_flip_parity_1896.py): real-WebsocketCommunicatorDEBUG-vs-PRODUCTION parity (a DEBUG event frame carries_debug,timingunder expose-timing; a prod frame carries NEITHER_debug/timing/performancenor 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 theon_event_framefold + the context threading off makes the 8 behavior-meaningful tests RED. -
The SSE
/event/alias now forwards the client-sentrefso the #560 ref echo works on BOTH SSE endpoints (#1891). The/message/endpoint forwards the raw body verbatim toruntime.dispatch_message, so a client-supplied top-levelrefreacheddispatch_eventand 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 DROPPEDref— so the runtime's_dispatch_event_render(which readsreffrom the top level of the data dict) sawNoneand echoed nothing, leaving the end-to-end ref echo exercised only via/message/.DjustSSEEventView.postnow carriesrefthrough into the dispatch frame ({type, event, params, ref}); the runtime coerces it to int / None, so no endpoint-side validation is needed.paramsalready carried_cacheRequestId/component_id/view_id(SSE has neither component nor sticky-child routing), sorefwas the only dropped field. New cases inTestSSEEventAliasRefEcho(python/djust/tests/test_sse_runtime_convergence_1887.py, real-SSE end-to-end: update + noop frames echo the ref over the/event/alias) andTestDjustSSEEventViewPost::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-dropsrefto confirm absence) stays green. -
component_id-routed WebSocket events now re-render the parent and emithtml_updateinstead of erroring (#1898, fixed by #1907 THE FLIP). The deleted bespoke_handle_event_innercomponent_idbranch resolved + ran the LiveComponent handler but never re-rendered the parent view:htmlstayedNone, the html_update fallback strippedNoneand raisedTypeError, andhandle_exceptionturned it into anerrorframe — so a working component event surfaced to the client as an error with no DOM update. Now that WS events route throughViewRuntime.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-scopedhtml_updatecarrying the parent's updated state (e.g. values pushed up viasend_parent), and echoes the eventref. The#1896parity net'scomponent_idtest is updatederror→html_update(the single intended behavioral change of the flip); its gate-off sibling (a boguscomponent_idstill errorsComponent not foundat 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_layoutswaps, accessibility announcements, and i18n commands queued duringhandle_params()(a live parallel-path-drift instance, #1646, INSIDE the convergence target). The runtime now has a single_flush_all_pendingthat drains all 8 queues in WebSocket's exact canonical order (mirrorswebsocket.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,TestWsOnlyBehaviorEnumerationinpython/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_URLCONFleak, PR #1874), #1875 (djust_hotreloadchannel-layer pollution, PR #1881), and #1882 (process-global wire-version drift — a straydjust_hotreloadframe on the cachedInMemoryChannelLayerre-renders on a later consumer and bumps its per-connection_next_version()counter, sotest_time_travel_jump_recovery_version_is_currentsaw 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 helperdjust.test_isolation.reset_djust_globals()(DRY, #1646) called by an autouse_reset_djust_globalsfixture in BOTH test roots (tests/conftest.py, mirroringcleanup_session_cache; andpython/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-levelitertools.countid 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 touchstate_backend(already isolated bycleanup_session_cache), the keyed self-invalidating_jit_serializer_cache, the one-shot_CUSTOM_FILTERS_BRIDGEDbootstrap, or per-instanceStickyChildRegistry._child_views. The #1882 cure is proven deterministically + gate-off (#1468) inpython/djust/tests/test_global_isolation_1883.py: a stale-layer siblinggroup_sendreproduces the exactgot 4drift WITHOUT the reset and the clean1 -> 2 -> 3chain WITH it, plus per-global unit pins (neuteringreset_djust_globalsfails 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
#1721theme-tag tests under-n auto— the systemic#1883fixture now re-asserts theready()-time Rust tag handlers (#1928, #1883-class).python/djust/tests/test_theme_tags_rust_engine_1721.pyflaked under full-n auto:has_tag_handler("theme_panel")returnedFalseand all 17 tests 500'd withUnsupported 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, andDjustThemingConfig.ready()/DjustComponentsConfig.ready()register the{% theme_X %}/{% render_slot %}handlers only ONCE per process.tests/benchmarks/test_tag_registry.py::TestRustPythonInteropclears the registry (clear_tag_handlers()) and itsrestore_registryfixture restores ONLY thedjust.template_tagsbuilt-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 thatdjango.setup()s withoutdjust.theming). This is the exact #1771 bug fixed only intests/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 bothready()-time registrars BEFORE every test in both test roots — idempotent (theming guards onhas_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 inpython/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_socketunder-n auto(#1875). The #291 regression test (a login-redirecting view in amount_batchmust NOTclose()the shared socket) was order-fragile under full-n autosaturation — 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-globaldjust_hotreloadchannel-layer group on connect, so a sibling test'sgroup_send("djust_hotreload", ...)could deliver a stray frame into the test'sreceive_nothingwindow — now isolated by clearing the cached channel-layer backend so the consumer connects to a fresh, unpollutedInMemoryChannelLayer; (2) thereceive_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 deterministicping→pongopenness probe (a closed socket cannot pong). Gate-off verified (#1468): removing the_mounting_in_batchclose-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. -
V004no longer false-fires on framework-invoked lifecycle hooks (#1684). TheV004system 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 theV004lifecycle-skip set inchecks/components.py. Canonical symptom:handle_presence_leave(bitdjust-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 the1.1branch (#1685) against the pre-#1822-splitchecks.py; it was never ported tomain's splitchecks/(so the false-positive was live through 1.0.8) — this lands it onmain. New regressionTestV004LifecycleMethods::test_v004_ignores_framework_invoked_hooks_1684(gate-off verified, #1468).
Security
-
ViewRuntime.dispatch_mountgained 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 ofmount(); the payload is a server-signedTimestampSignerblob (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_mountnow ports the WS restore VERBATIM (websocket.py:2491-2587): the sameunsign_snapshot(blob, slug=view_path, sid=session_key)HMAC binding (a snapshot signed for view A / session S1 / older thanDJUST_STATE_SNAPSHOT_MAX_AGEdoes NOT restore), the same size cap (64 KB verified inner JSON), keyset cap (256 keys), dict-type cap, theDJUST_STATE_SNAPSHOT_ENABLEDoperator master-switch, and the_should_restore_snapshot(request)view-level veto. The session key for thesidbinding is sourced fromrequest.sessionand 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_snapshoton the mount frame,websocket.py:2754-2792) is also ported, opt-in only. Gatedenable_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 UNTOUCHED —handle_mountkeeps its own copy until the Phase 3.3b flip;RUNTIME_OWNED_VERBS/ WS routing /handle_mount_batchare unchanged (websocket.pyhas no diff). New suitepython/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 themount()default), each with a gate-off sibling (#1468). Gate-off verified: skipping the slug cap inunsign_snapshotmakes 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. -
ViewRuntimegained atransport.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 WShandle_eventalready re-checks per-event auth whenLIVEVIEW_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. NewTransport.recheck_event_auth(view) -> bool(default-True = no re-check) wired intoViewRuntime._dispatch_event_innerat the SAME point WS does — after the view-mounted check, BEFORE the actor branch and the handler.WSConsumerTransportreplays the WS bespoke logic verbatim (re-resolve the user from the scope session viachannels.auth.get_user, reflect ontoview.request.user, re-runcheck_view_auth_lightweight; on failurenavigateto the login url +close(4403)).SSESessionTransportre-checks against the LIVE event-POST request (session._event_request, stamped by the/event/+/message/endpoints just before dispatch — the current POSTer'srequest.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 onreauth_on_event+login_required/permission_required(default views pay nothing). #291 multiplexed-path care: the runtime clearsview_instanceUNCONDITIONALLY on aFalsereturn (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_batchis mount-only — but the close stays gateable if events are ever collected, matching the WS bespokeview_instance = Noneafter 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 suitepython/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_instancecleared; 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_mountbefore it could go live (#1885, ADR-022 Iter 0). The WebSockethandle_mountenforces the ADR-017 post-mount object-permission check (check_object_permission), butViewRuntime.dispatch_mountdid not — so a view whosehas_object_permission()returnsFalse(or whoseget_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_mounthas 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 sharedenforce_object_permissionchokepoint the other transports use (runtime.py, mirroringwebsocket.py:2554-2573), placed AFTERmount()(soget_object()can read URL-derived attrs) and BEFOREhandle_params+ render (so a denied object is never rendered or sent). Fail-closed; a no-op for views without a customget_object(behavior-preserving). Reproduce-first + gate-off (#1468) verified: a denied view mounts + leaks its rendered HTML before the fix, emits only apermission_deniederror frame after. New cases inTestDispatchMountObjectPermission(python/djust/tests/test_transport_behavioral_parity.py).
[1.0.8] - 2026-06-23
Security
sanitize_for_lognow carries a CodeQL-recognized CR/LF barrier (#2465/#2466 —py/log-injection). The first log-hardening pass wrapped the gate-rejection paths insanitize_for_log, but itsisprintable()-loop (which maps line breaks to?) is not a modeled CodeQL barrier, so thesanitize_for_log(request.path)calls inapi/openapi.py+observability/views.pystayed 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 everysanitize_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 bytest_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) — theDJUST_TRUSTED_PROXY_COUNTmisconfiguration warning inpython/djust/_client_ip.pynow logs the offending value's TYPE (type(raw).__name__), not the rawsettingsvalue (CodeQL treatssettingsreads 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_gateandobservability/views.py:_gatenow wrap the user-controlledrequest.pathwith the existingdjust._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 acrossapi/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):msgpack1.1.2 → 1.2.1 (GHSA-6v7p-g79w-8964 — HIGH; out-of-bounds read / crash onUnpackerreuse after a caught error —msgpackis on djust's WS wire-protocol path),ujson5.12.1 → 5.13.0 (CVE-2026-54911 / GHSA-3j69-69wj-xqx2 — malformed/truncated UTF-8 silently rewritten indumps()),pydantic-settings2.14.0 → 2.14.2 (GHSA-4xgf-cpjx-pc3j —NestedSecretsSettingsSourcesymlink-follow outsidesecrets_dir). npm (package-lock.json, dev/test — transitive viajsdom):undici7.25.0 → 7.28.0, closing six advisories at once — CVE-2026-9697 / GHSA-vmh5-mc38-953g (HIGH, TLS cert-validation bypass via droppedrequestTlsin 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 (nopyproject.toml/package.jsonchange). Full pytest suite (incl. the wire-protocol / WebSocket paths exercisingmsgpack) + the 1743-case JS suite green on the bumped versions. - Made the
_ALWAYS_EXCLUDED_FIELDSserialization floor UNCONDITIONAL — a per-model allowlist can no longer re-exposepassword/is_superuser/is_staff(#1868 — CWE-200/CWE-359).serialization.py:_field_is_serializablechecked the per-modeldjust_serializable_fieldsallowlist BEFORE the_ALWAYS_EXCLUDED_FIELDSfloor, so a model declaringdjust_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-outdjust_serialize_sensitive_fields— a developer must explicitly take ownership of shipping a password hash / privilege flag (default is always deny). The single_field_is_serializablefunction gates all three auto-serialization callers (model fields,get_*methods,@propertyvalues), 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 viadjust_serializable_fieldswill no longer ship it; add the field todjust_serialize_sensitive_fields(a separate, explicit declaration) if that exposure was intentional. Regression: new cases inTestUnconditionalFloor(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 includingtest_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 shareddjust.auth.core.enforce_object_permissionchokepoint) 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_apianddispatch_server_functionmounted an object-scoped view and rancheck_view_auth/check_handler_permissionbut never the object-level check, so anexpose_apihandler /@server_functionran 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 callenforce_object_permission(view, request)after view-level auth +mount()(soget_object()'s access-determining state exists), before the handler runs / the render: the API paths return 403permission_deniedand 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 customget_object(behavior-preserving for non-object-scoped views) and fail-closed on denial / aNonerequest / any non-PermissionDeniedexception. Regression: new cases inpython/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 eachenforce_object_permissioncall turns the matching denies test red. The concern-4c structural pin inpython/djust/tests/test_mount_chokepoint_structural.py::TestMountOrchestrationChokepointis extended to assertenforce_object_permissionis referenced onsse.py(>=1) andapi/dispatch.py(>=2 call sites) so a future removal is caught. - WS
receive()routes theurl_changeverb through the singleViewRuntime.dispatch_messagechokepoint (#1852).LiveViewConsumer.receive()previously dispatchedurl_changestraight todispatch_url_change, BYPASSINGdispatch_message— the chokepoint the SSE transport already routes every inbound frame through. It now routesurl_changevia_dispatch_runtime_owned→ViewRuntime.dispatch_message(runtime.py) so a future security/policy control added at that chokepoint auto-applies to the WebSocket transport. AddsRUNTIME_OWNED_VERBS = frozenset({"url_change"})as the explicit pinned chokepoint set plus a documented WS-only extension set inreceive().mount(sticky/snapshot/actor — deferred to T1-A #1853) andevent(~16 WS-only behaviors the runtime path lacks) deliberately stay on their WS handlers. Behavior-preserving; wire output forurl_changeis identical. New cases inTestUrlChangeRoutedThroughChokepoint,TestUrlChangeEndToEndPreserved,TestWSOnlyFramesPreserved,TestRuntimeOwnedVerbsContract(python/djust/tests/test_ws_receive_runtime_dispatch_1852.py), gate-off-verified (#1468) via a realWebsocketCommunicatorspy ondispatch_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, andTestOriginParity(inpython/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_limittrip point (caller_key+handler_rate_check), and foreign-Origin/Host rejection (_is_allowed_origin/_host_in_allowed_hosts).TestMountOrchestrationChokepoint(inpython/djust/tests/test_mount_chokepoint_structural.py) adds an AST count-canary pinning thatwebsocket.py+runtime.pyeach 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 inLiveViewConsumer.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, signedstate_snapshotrestore, actor wiring, render) are untouched. Behavior-preserving: the helper returns exactly whatcheck_view_authreturns and propagatesPermissionDenied/_ensure_tenantexceptions, so each caller's existing envelope is unchanged; tenant resolve/bind is skipped on auth denial as before. Hardening side effect: a non-PermissionDeniederror 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 throughrun_pre_mount_authand the helper body still invokes its leaf chokepoints. New cases inTestRunPreMountAuthHelper,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) — neuteringrun_pre_mount_authto 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) andS011(inline-script / CSP). Both live inpython/djust/checks/security.pyand reuse the existing@register("djust")+_has_noqa+_is_liveview_subclassscaffold. S009 (Warning, AST) flags a LiveView that declares VIEW-level authorization (a truthylogin_required/permission_requiredclass attr, acheck_permissionsoverride, a Django/djustAccessMixin-family base, or an auth-gateddispatch) yet exposes a PUBLIC@event_handler/@actionmethod with NO per-handler gate (@permission_required) and no class-levelcheck_handler_permissionoverride — 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 realdj-root/dj-viewsubtree when no CSP is configured (no django-csp middleware, noCONTENT_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 skipssrc-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 sodata-src/data-type/data-noncearen'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 inTestS009EventHandlerNeedsAuthandTestS011InlineScriptCsp(inpython/tests/test_checks.py). Dogfooded (#1060) againstexamples/demo_projectwith 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. Newtests/playwright/test_browser_smoke.pydrives a dedicated/demos/browser-smoke/LiveView (BrowserSmokeView+ template, inexamples/demo_project/djust_demos/) with two assertions: (A) a mount canary —dj-click="bump"round-trips0 -> 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-blockingplaywright-testsleg (continue-on-error, NOT in thetest-summaryAND-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_FIELDSunion + per-modeldjust_exclude_fields/djust_serializable_fields), (2) HMAC signed snapshots (security/state_snapshot.pysign_snapshot/unsign_snapshot— TimestampSigner, slug+session binding, fail-closedNoneon tamper/expiry — plus the private-attr signing boundary:live_view.py_capture_snapshot_stateexcludes_*attrs from the signed blob and_restore_private_staterestores 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_gateDEBUG→opt-in-setting→authenticated→non-disclosing-404, siblingobservability/views.py:_gate, andauth/core.py:run_pre_mount_authas the mount-auth single-source), and (4)safe_setattr(security/attribute_guard.pydunder/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-runtest_transport_parity_security.py+test_mount_chokepoint_structural.pyagainst new transports). The one-line "Secure defaults" item indocs/PULL_REQUEST_CHECKLIST.mdis 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 fromdocs/README.md(new Security section) and CLAUDE.md Additional Documentation. Every citedfile:symbolwas grep-verified at write time (#1197) and the four patterns' runtime behaviors empirically confirmed (denylist floor,safe_setattrblock/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.ymlworkflow 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 syntheticsubprocess(shell=True)trigger (fails).
Fixed
- CodeQL code-quality cleanups (Note severity). Removed the unused module-global
logger(and its now-orphanimport logging) frompython/djust/checks/utils.py(py/unused-global-variable, CodeQL alert 2418), and converted theTransportProtocol's bare...stub bodies inpython/djust/runtime.pyto one-line docstrings — the protocol methods are now documented and CodeQLpy/ineffectual-statement(alerts 2463 / 2464) no longer flags the Ellipsis statements. No behavior change (the concreteWSConsumerTransport/SSESessionTransportimplementations are untouched). - Test-ordering pollution:
TestT016DjNavigateWithoutRoutesleakedROOT_URLCONF, breakingTestDemoRegistrationunder-n auto(#1862). The four T016 check tests inpython/tests/test_checks.pycombined@override_settings(ROOT_URLCONF=...)with the pytest-djangosettingsfixture 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_settingsvalue wins, soROOT_URLCONFstayed pinned at the routeless test URLconf (tests.api_test_urls_unmounted) for the rest of the xdist worker. Whentests/unit/test_demo_views.py::TestDemoRegistrationlanded in the same worker afterward, its fourresolve()tests (test_pwa_view_in_urlconf,test_tenant_view_in_urlconf,test_service_worker_url_resolves,test_manifest_url_resolves) raisedResolver404. Fix: the T016 tests now setROOT_URLCONFvia the singlesettings-fixture mechanism (settings.ROOT_URLCONF = ...) instead of@override_settings, so one restoration path handles every mutated setting. Defense-in-depth:TestDemoRegistration.setup_methodnow callsdjango.urls.clear_url_caches()so it never depends on a clean resolver cache. New regression cases inTestT016DoesNotLeakRootUrlconf(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 thedj-rootnow 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 assignscontainer.innerHTML = data.html. Per HTML spec, a<script>inserted by clone+insert (morph) or byinnerHTMLis parsed but NOT evaluated, so an inline page<script>inside thedj-root(e.g. one registering a delegateddocumentclick 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 singlewindow.djust._runInsertedScripts(container)helper (python/djust/static/djust/src/03-websocket.js) that re-creates each classic<script>viadocument.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 andapplication/json/importmapare left untouched. Idempotent via adata-djust-script-ranmarker 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 morphdescribe block (tests/js/mount-morph-script-exec-1848.test.js, 6 cases); repro-first builds thedj-rootviainnerHTMLwith inter-element whitespace + real<script>nodes (#1650 fidelity), drives the real helper from the built bundle, and asserts a delegateddocumentclick listener registers; gate-off-verified (#1468) — neutering the helper turns 4/6 cases red. Gzipped bundle delta: 172 bytes. url_change/dj-patchframes now stamp the consumer-owned wire version, ending the guaranteed VDOM version mismatch + forced reload (#1858, the #1788 parallel-path twin / #1646).url_changeframes (fromdj-patchclicks and browser popstate) are delegated toViewRuntime.dispatch_url_change, which stamped the wireversiondirectly fromrender_with_diff()'s return — the Rust render counter — andWSConsumerTransport.sendforwarded 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 firsturl_changeframe disagreed with the mount baseline, the client'sclientVdomVersion === data.version - 1check failed, and the client hit a non-recoverable error → forced page reload.dj-clickworked (it stamps_next_version());dj-patchdid not — that asymmetry was the fingerprint. #1788 had unified the wire version onto the consumer counter forhandle_mount/handle_eventbut not theViewRuntimedelegate paths. Fix: a newTransport.next_client_version(html, rust_version)hook is called by the runtime's render-send sites (dispatch_url_changeand_render_and_send);WSConsumerTransportreturnsconsumer._next_version_armed(html)— the same per-connection counterhandle_eventuses — which keeps the wire version monotonic with the mount baseline AND armsrequest_htmlrecovery to that version (#1788 / #1817), whileSSESessionTransportreturns 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 realWebsocketCommunicatorround-trips: 3 regression cases inpython/djust/tests/test_url_change_wire_version_1858.py(test_url_change_stamps_consumer_version_not_rust_counterasserts the dj-click/dj-patch asymmetry + the[1, 2, 3, 4]no-collision chain,test_version_monotonic_across_click_then_urlchange_then_clickpins the strict sequence across the url_change boundary,test_url_change_arms_recovery_to_its_own_versionpins #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 soS009/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,S009early-returned, andS011saw 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 againstexamples/demo_projectfrom 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 inTestGetProjectAppDirsDiscovery1865(python/tests/test_checks_app_dir_discovery_1865.py) exercise the real helper (mockingapps.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 theexpose_apiattack 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.DEBUGis True, (2) the new opt-insettings.DJUST_API_OPENAPI_PUBLICis 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 missingrequest.user(noAuthenticationMiddleware) or an anonymous user falls through to the 404. New setting:DJUST_API_OPENAPI_PUBLIC(defaultFalse) — set toTrueonly if you intend the OpenAPI spec to be readable by unauthenticated clients. Default-behavior change (action required for some apps): withDEBUG=Falseand the setting unset, anonymousGET /djust/api/openapi.jsonnow returns 404 instead of the schema; authenticated developers/integrators and DEBUG/dev environments are unaffected. Regression: new cases inTestOpenAPIGateF29—python/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 WebSockethandle_mountandViewRuntime._build_requestreconstructed the request viaRequestFactory().get(...)with noHTTP_HOST, sorequest.get_host()defaulted to"testserver"on the live path. Host/subdomain resolvers (e.g.SubdomainResolver, which readsrequest.get_host()) therefore misresolved the tenant toNoneover WebSocket — while the HTTP (SSR) initial render, using the real request, resolved the correct tenant. WithSTRICT_MODE=Falsethe tenant-scoped managers then returned unscoped rows (cross-tenant disclosure) in WS event handlers; with the defaultSTRICT_MODE=Truethey returned.none()(broken tenancy, and wrong/no tenant stamped on WS writes). The validated clientHost(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 theHostagainstsettings.ALLOWED_HOSTSusing 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_HOSTSHostfalls back to the prior default (non-browser clients keep working; a spoofedHostgains no tenant authority beyond HTTP). Covered byTestHttpWsTenantParity,TestAllowedHostsBound,TestNoHostFallback,TestSchemePropagation,TestMalformedHostRejected, andTestGateOffinpython/djust/tests/test_ws_host_tenant_f26.py. -
Unified the per-handler
@rate_limitinto 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-connectionConnectionRateLimiter(websocket_utils._validate_event_security→rate_limiter.check_handler), so opening N WebSocket connections gave N× 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_keykeyed unauthenticated callers by rawREMOTE_ADDRinstead of the #5-hardenedresolve_client_ip(which honorsDJUST_TRUSTED_PROXY_COUNTand is already used by WS/SSE) — so behind a reverse proxy (standard prod topology) all unauthenticated callers collapsed to oneip:<proxy>bucket (ineffective per-client limiting + mutual DoS), and under a naive XFF→REMOTE_ADDRmiddleware 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 indjust.rate_limit(handler_rate_check(caller_key, handler_name, settings)over anOrderedDictofTokenBuckets keyed(caller_key, handler_name)) is now the sole enforcement point for the per-handler@rate_limit, used by all three transports. A sharedcaller_key(request, client_ip)mirrors the SSE owner-principal identity model (Findings #24/#25):user:<pk>when authenticated, elsesession:<session_key>, elseip:<resolved_ip>— and its IP fallback resolves throughresolve_client_ip(closing F28: per-real-client buckets behind a trusted proxy, no XFF spoof). The WS/SSE/runtime chokepoint_validate_event_securityandapi/dispatch._rate_limit_checkboth route through the shared store;api/dispatch._caller_keynow resolves the IP viaresolve_client_ipand its misleading "REMOTE_ADDR should already reflect the caller via their proxy middleware" comment is removed. The GLOBAL per-message abuse-disconnect (#17) is untouched —ConnectionRateLimiter.check/check_upload/should_disconnect(the connection-floodclose(4429)+ IP cooldown inwebsocket.py:receive) stays per-connection, which is correct for connection-flood control; only the per-handler@rate_limitis unified. Net invariant: a given caller has ONE@rate_limitbudget per handler regardless of connection count or transport. Regression: 11 regression cases inpython/djust/tests/test_ratelimit_per_caller_f27_f28.py(test_f27_*multi-connection/multi-context/cross-transport/two-WebsocketCommunicatorend-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_keyto rawREMOTE_ADDRmakes 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 (
viewdotted path and pageurl) are reached over three transports — WebSockethandle_mount, SSE_sse_mount_view, and the genericViewRuntime.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].ClassNameof valid identifiers — rejecting.., leading/trailing dots, and bad characters), checks an allowlist before importing anything, imports viaimportlib.import_module+ avars(module).get(name)__dict__lookup (nevergetattr, so a client class name cannot trigger a PEP 562__getattr__submodule import — GHSA-7prp-2623-8g45 follow-up), and only then runs theLiveView-subclass check as defense-in-depth. The allowlist now uses module-segment-boundary matching (path == entryorpath.startswith(entry + ".")) instead of the old boundary-lessstartswith(which let["myapp"]admitmyapp_evil.views.Pwn). F23 (SSE traversal): the #1819/#1825 mount-URL validator (validate_mount_url, moved here verbatim;websocket._validate_mount_urlis now a thin alias) is applied inViewRuntime.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 toLIVEVIEW_ALLOWED_MODULESwhen 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 eachINSTALLED_APPSentry plus"djust". This blocks arbitraryos/antigravity/site-packages imports out of the box (the prior default allowed any already-imported module, whichosalways defeats) without breaking the common case of an app that never setLIVEVIEW_ALLOWED_MODULES— legitimateLiveViewclasses live inside installed apps. Apps that mount a lazily-imported view living outside an installed app must now add its module toLIVEVIEW_ALLOWED_MODULES. Regression: 17 regression tests inpython/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 fortype:"server_push"messages, sent bypush_to_view/apush_to_view) applied the messagestatedict via rawsetattr(self.view_instance, key, value)in a loop — while the siblinghandlerfield 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). Rawsetattrlet 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 throughsafe_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 byte0x01/0x02/0x03, len ≥ 17) were dispatched to_handle_upload_frameandreturned inreceive()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 onConnectionRateLimiter(check_upload(); defaultsupload_rate=200/s,upload_burst=400, configurable viaLIVEVIEW_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 tripsshould_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-chunksend_jsonresponse 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_exemptand 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/withcredentials: includeto fire state-changing event handlers as the victim. The@csrf_exemptjustification was false on three counts: the URLsession_idis client-chosen (DjustSSEStreamView.getvalidates only its UUID format, never that the server issued it), so it is not a CSRF token; and a JSON body sent withContent-Type: text/plainis a CORS simple request accepted by the handlers (whichjson.loadsthe 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 requestOriginagainstsettings.ALLOWED_HOSTS(reusingdjust.websocket._is_allowed_origin) and reject cross-origin requests with 403 before any session create/mount or event dispatch — a browser always sendsOriginon a cross-origin request, so an attacker page's origin won't match; same-origin requests pass; non-browser clients (noOrigin) still work. As defense-in-depth, the POST endpoints now requireContent-Type: application/json(415 otherwise), closing thetext/plainsimple-request bypass and forcing a CORS preflight cross-origin.@csrf_exemptis 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-originOriginheader (or noOrigin, for non-browser clients) andContent-Type: application/json. The bundled djust client already sends both. Custom SSE clients that POST withtext/plainor from a different origin must change to sendapplication/jsonand a same-originOrigin(or noOriginfor 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.tenantsisolation was enforced only on the HTTP path. The current tenant was stored inthreading.local()and set exclusively byTenantMiddleware(HTTP-only), so on the live (WebSocket/SSE) pathget_current_tenant()was alwaysNoneduring mount + every event handler — and the tenant-aware managers failed OPEN:TenantQuerySet._filter_by_tenantreturned the unfiltered queryset and never consultedSTRICT_MODE, disclosing every tenant's rows to whoever held the socket.TenantQuerySetadditionallyRecursionError'd whenever a tenant was set (its_chainoverride re-enteredfilter()→_chain→ ...), andModel.objects.all()was unfiltered even with a tenant bound. Fixed four ways: (A) tenant storage is now acontextvars.ContextVar(per async-task), notthreading.local()—threading.localis shared across all connections on asgiref'sthread_sensitivesync_to_asyncexecutor thread (a cross-tenant clobber), whereasContextVaris copied per-call into the executor so each connection stays isolated; the public API (get_current_tenant/set_current_tenant) is preserved and a newtenant_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 (WShandle_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 sharedget_queryset()helper (no recursion;.all()is scoped too) and fail CLOSED — with no tenant bound they return.none()underSTRICT_MODE(the default), matchingTenantManager; unfiltered only whenSTRICT_MODEis explicitlyFalse. (D) New system check S006 warns whenDJUST_TENANTS['STRICT_MODE']is explicitlyFalse(disables fail-closed isolation; risks cross-tenant disclosure). Migration note: storage moved fromthreading.local→ContextVar(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, useModel.objects.unscoped(reason=...)for deliberate cross-tenant reads, or setDJUST_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 theLiveView-subclass check and per-view auth, and theLIVEVIEW_ALLOWED_MODULESguard was fail-open (skipped when unset, the default) with loosestartswithmatching. 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 matchesLIVEVIEW_ALLOWED_MODULESon a module-segment boundary (no longerstartswith). The class is resolved viaimportlib.import_module(nofromlist) +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'sView.dispatch()chain — so standard Django authorization (LoginRequiredMixin,UserPassesTestMixin,@method_decorator(login_required, name="dispatch"), customdispatch()guards) and djust's own admin extension (gated only by the HTTPas_viewwrapper) 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_authnow honors the DjangoAccessMixinfamily (LoginRequiredMixin/PermissionRequiredMixin/UserPassesTestMixin), mirroringhandle_no_permission()semantics and leaving the view'srequestattribute 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 — aLiveViewsubclass with@method_decorator(<auth>, name="dispatch")or an overriddendispatch()that performs authorization itself — pointing the developer at the djustlogin_required/permission_required/check_permissionsattributes or a supported mixin; (3)admin_ext.AdminBaseMixinnow declareslogin_required = True+ acheck_permissionsactive-staff gate so the admin extension is staff-gated on every transport. TheAccessMixinfamily is honored automatically; the decorator/overridden-dispatchforms are surfaced by S004 rather than auto-honored (they're HTTP-only and cannot be replayed without producing anHttpResponse). Regression + detection-canary tests intest_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), SPAurl_changenavigation (ViewRuntime.dispatch_url_change), or{% live_render %}embedded children — so an object-scoped view rendered a denied object on those paths. A shared chokepointdjust.auth.core.enforce_object_permission(no-op for views without a customget_object; raisesPermissionDeniedon denial; fail-closed on aNonerequest or any non-PermissionDeniedexception) is now called from all three: the HTTP render returns 403,url_changesends apermission_deniederror 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 leftmostX-Forwarded-Forvalue unconditionally and used it for per-IP connection limiting + cooldown/ban — a client-controlled identity. An attacker could rotateX-Forwarded-Forto 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 shareddjust._client_ip.resolve_client_ip: by default the real socket peer (REMOTE_ADDR/ ASGIscope["client"]) is used andX-Forwarded-Foris ignored; behind a trusted reverse proxy set the newDJUST_TRUSTED_PROXY_COUNT = Nsetting 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/actionsinks (CWE-79). Built-in component tags rendered a developer/user-supplied URL into anhref/actionattribute withconditional_escape(HTML-entity escaping) but no scheme validation — so ajavascript:URI (which contains no escapable characters) landed verbatim and executed on click. A newdjust.components.templatetags._registry.safe_urlhelper HTML-escapes and neutralizes dangerous schemes (javascript:/vbscript:/data:, including control-char/whitespace-obfuscated variants) to#, while preservinghttp(s)/mailto/tel/ftpand relative/anchor/query URLs. Applied at all 11 navigation-context URL sinks acrossdjust_components.py(breadcrumb,dj_navlink/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.dumpsdoes 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, whereget_debug_info()includesrepr()s of user-controlled public view attributes) andjs.JSChain.__html__(whose docstring also falsely claimed JSON escapes</>). A newdjust.security.escape_json_for_scripthelper translates<,>,&(andU+2028/U+2029) to\uXXXX— matchingdjango.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).ModelBindingMixinis in theLiveViewbase MRO, so every LiveView exposes a default@event_handler update_model(field, value)thatsetattrs a view attribute whose name is client-supplied. The only gates were: reject_-prefixed names, reject the 14-entryFORBIDDEN_MODEL_FIELDSdenylist, optionally require membership inallowed_model_fields(which defaulted toNone= allow ALL public attrs), and requirehasattr. So a client could set any public, existing view attribute —is_admin,account_id,total_price, … — not just the fields actually bound withdj-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 staticdj-model="<field>"binding fromNode::Textliterals (covering{% extends %}and{% include %}), exposed to Python asRustLiveView.dj_model_fields()(and a module-leveldj_model_fields_from_template(source, dirs)for embedded children). This is recorded onself._dj_model_fieldson every render viaModelBindingMixin._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::Variablesubstitution 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|safecontent carrying<input dj-model=…>) cannot widen the allowlist.update_modelis fail-closed — a field is bindable iff it is inself._dj_model_fields(auto-allowlist) OR in an explicitallowed_model_fields(union semantics); the_-prefix /FORBIDDEN_MODEL_FIELDS/hasattrchecks 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_diffis the dominant one (HTTP-GET baseline + every WS mount + every WS event)._dj_model_fieldsis assigned before the_framework_attrssnapshot so it is a framework slot (recomputed each render, never persisted). Migration note: the auto-allowlist now covers staticdj-model=bindings in templates (including{% extends %}/{% include %}). A dynamic bindingdj-model="{{ var }}"(resolved at render time) and any field written purely programmatically are not auto-allowed — add those names toallowed_model_fields. Existing staticdj-model="x"bindings keep working with zero config. Regression:test_update_model_allowlist.py(the three poisoning vectors verified end-to-end throughrender_with_diff, reproduce-first + gate-off verified, plus a real-render integration test) and Rust unit tests incrates/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 thelive_redirect_mountback-navigation path, in lieu of callingmount(). 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_snapshotsafe_setattrs every public key from the client'sstate_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 bysafe_setattr's attribute-name regex (which permits ordinary public names). Fixed by signing the snapshot server-side with Django'sTimestampSigner(keyed onSECRET_KEY, salt"djust.state_snapshot") in a new shared helperdjust.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 plaintextpublic_statedict; 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 throughunsign_snapshotbefore 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 normalmount()withmounted_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 plaintextstate_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 tomount(). The bundled djust client handles the new opaque blob automatically. Any custom client or any view that overrides_capture_snapshot_state/_restore_snapshotmust round-trip the signed blob verbatim (do not re-serialize it) — the single field namestate_jsonstill carries the blob end-to-end. Configure the TTL viaDJUST_STATE_SNAPSHOT_MAX_AGE(seconds, default 3600). Regression:python/djust/tests/test_state_snapshot_signing.py(realWebsocketCommunicatorforge-rejection + signed round-trip, plus tamper / expiry / cross-view / cross-session / anonymous; reproduce-first + gate-off verified) andtests/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 theeval_handlermethod-invocation endpoint) gated onsettings.DEBUGin every view, but the localhost check lived only in the opt-inLocalhostOnlyObservabilityMiddleware— omitted from the documented setup and not auto-installed — so withDEBUG=Trueand the middleware absent (e.g. a0.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_handlernow 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 theMIDDLEWAREentry. 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_safelyserialized every concrete field of a Django Model, so assigning a Model with sensitive fields to a public LiveView attribute (the naturalself.user = request.userpattern that djust's own_private/publicconvention encourages) sent fields such as thepasswordhash and theis_superuser/is_staffprivilege flags to the browser — across all client-bound serialization paths (the JIT full-dump fallback, the opt-in state snapshot, andget_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 dropspassword,is_superuser, andis_staff;settings.DJUST_SENSITIVE_FIELDS(any iterable) unions project-wide additions on top of the floor; a per-modeldjust_exclude_fieldsiterable drops additional fields; a per-modeldjust_serializable_fieldsallowlist, 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-levelto_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, andis_staffare no longer serialized for any Model by default; add field names tosettings.DJUST_SENSITIVE_FIELDSor per-modeldjust_exclude_fieldsto drop more, use a per-modeldjust_serializable_fieldsallowlist to restrict to an explicit set, or define a modelto_dict()to fully control the payload. Regression:python/djust/tests/test_serializer_field_exposure_f19.py— classesTestSensitiveFieldDenylist,TestSettingsOverride,TestPerModelControls,TestToDictOverride,TestJitEmptyPathsFallback, and the gate-off sentinelTestGateOff(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_nameis the raw, attacker-controlled original filename, and theUploadEntry/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. OnFileSystemStoragea value like../../../etc/xraisesSuspiciousFileOperation(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 propertyUploadEntry.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, andU+202Ebidi-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, soevil.png.would otherwise collide withevil.png), and falls back to"upload"if nothing safe remains — mirroring the intent of Django'sStorage.get_valid_name/ werkzeug'ssecure_filenamewhile preserving ordinary names (my report (1).pngstays readable). The class docstring now usessafe_client_namein thedefault_storage.save(...)example and documentsclient_nameas the raw injection-prone field.client_nameitself 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 rawclient_nameinterpolated into a storage.save(...)/os.path.join(...)path, ignoringsafe_client_nameand plain display interpolation (zero false positives against the demo project). Migration note: useentry.safe_client_name(notentry.client_name) when building any storage path or object-store key;client_nameremains the raw original filename for display only and must go through HTML auto-escaping (orescape()) — 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"]), andvalidate_magic_bytesis 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 commonaccept="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 theaccept/image/*wildcard:UploadManager.register_entry(primary gate) andUploadManager.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 helperdjust.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-8andimage/svg+xml;are matched asimage/svg+xml, since browsers ignore the parameter when choosing the renderer), and the filename is run through the same_safe_basenamecanonicaliser thatUploadEntry.safe_client_nameuses (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 rawPath(...).suffixmiss (.svg≠.svg) while the storage normaliser still wrote it to disk asevil.svg.validate_magic_byteskeeps its permissive-for-unknown default so legitimatetxt/csv/json/png/jpg/pdfuploads 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 anaccept="image/*"slot. To accept them, setallow_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, andContent-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— classesTestActiveContentRejectedByDefault,TestActiveContentOptIn,TestBenignUploadsStillAccepted,TestFinalizeDefenseInDepth,TestMimeParameterBypass,TestFilenameCanonicalisationBypass,TestSvgzExtension,TestGateAgreesWithSafeClientName,TestSafeBasenameSharedHelper,TestIsActiveContentHelper, and the gate-off sentinelTestGateOffSentinel(reproduce-first + gate-off verified: neuteringis_active_contentmakes 25 security cases fail). -
Escaped + DEBUG-gated the embedded-child render-error path (CWE-79 / CWE-209).
LiveViewConsumer._render_embedded_childrolled its own error string in itsexceptblock and returned it as the embedded child's subtree HTML (delivered to the client as anembedded_childfull-HTML update), bypassing the framework's central, DEBUG-gatedcreate_safe_error_responsepath. 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 throughdjango.utils.html.escape(neutralising the comment-breakout and any tag injection in both modes —-->becomes-->,<script>/<imgbecome inert entities) and gating it onsettings.DEBUG: production now emits a detail-free<!-- Error rendering embedded child -->, mirroringsimple_live_view.render_template's DEBUG gate. As defense-in-depth, the{% live_input %}unknown-field_typeerror comment intemplatetags/live_tags.py(a template-author literal, not attacker input) was switched frommark_safe(f"... {field_type!r} ...")toformat_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.hrefsink (CWE-601 / CWE-79). The client applied navigation/redirect targets pushed over the wire by assigningwindow.location.href, but the guard was applied inconsistently across transports (parallel-path drift). The WebSocketnavigatehandler had an inline same-origin guard, while the SSEnavigatehandler (static/djust/src/03b-sse.js) assignedwindow.location.href = data.toraw, and the sharedlive_patch/live_redirectcross-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. Ajavascript:ordata:target parses to an opaque origin ("null"), which is!== location.origin, so it passed the cross-origin routing test and was assigned straight towindow.location.href, where the browser executes it — an open-redirect (CWE-601) andjavascript:/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 helperwindow.djust.safeNavigationTarget(value)(newstatic/djust/src/02b-safe-nav.js) returns a sanitized string ornull— for same-origin absolute paths (/foo,/foo?x=1#h) it re-resolves the candidate throughnew URL(value, window.location.origin)and accepts ONLY the canonicalizedpathname+search+hashif it genuinely resolves same-origin (a rawcharAt(1) !== '/'prefix check is NOT enough: the WHATWG URL parser normalizes\→/and strips ASCII tab/newline, so/\evil.com,/\/evil.com,/\t/evil,/\n//evilall resolve cross-origin despite starting/x— these are now rejected), accepts absolutehttp:/https:URLs (the legitimate #1599 sister-site case), and rejectsjavascript:/data:/vbscript:/blob:/file:schemes, opaque-origin results, protocol-relative (//evil.com) and backslash/control-char off-origin tricks, and unparseable/empty input (warning underglobalThis.djustDebug). It is now applied at everylocation.hrefnavigation sink: the SSEnavigatehandler, the WSnavigate/nav.tofallback (replacing the inline same-origin guard so the WS and SSE paths share one implementation and cannot drift apart again), thehandleLivePatch/handleLiveRedirectcross-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 tolive_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//evilall rejected) and a query+hash-preserved accept case proving the re-resolve path; the SSE andlive_patch/live_redirectsinks driven with both safe andjavascript:/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.getcreated anSSESessionkeyed by a client-chosensession_idand stored only the client IP, whileDjustSSEEventView.postandDjustSSEMessageView.postdispatched on it with no check that the POSTer owns the session — handlers ran with the mounter's capturedrequest.user. So anyone who learned a leakedsession_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_sessionsbefore 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_pkfor an authenticated mounter; a Django session key for an anonymous mounter, forced non-None viarequest.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 onlen(_sse_sessions)(503), both module constants overridable viaDJUST_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_sessionsonly 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
### Addedentry.
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 — WebSockethandle_mount, genericViewRuntime.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-levelpython/djust/*.pymodules with an explicit, comment-justified expected-site allowlist: it fails if a NEW unsanctionedimport_module(view_path)/__import__(<non-literal>),setattr(view, <non-literal key>, …), orfactory.get(<client URL>)(outside avalidate_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.getsite, makes the corresponding case FAIL.
[1.0.7rc1] - 2026-06-18
Added
- System check T017 — warn on
dj-view/dj-rooton a table-section element (#1837). A new template static-analysis check (severity WARNING) flags adj-viewordj-rootattribute 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/<tablefoodon't match, and tolerates attribute order / whitespace / case. The fix hint points the developer to a wrapping element (the<table>or a surrounding<div>). HonorsDJUST_CONFIG['suppress_checks']for both the short (T017) and fully-qualified (djust.T017) id forms. Covered byTestT017TableSectionRegexandTestT017CheckIntegrationinpython/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_recoverycaptures_recovery_version = _last_sent_versionat arm time;handle_request_htmlsends thehtml_recoveryframe 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_notifybroadcasts. After such a frame the client's applied version was ahead of_recovery_version, so a laterrequest_htmlreturned anhtml_recoverystamped with the stale_recovery_version— the client resetclientVdomVersionbackwards and the next successful diff'sdata.version - 1no 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 (itsresult['html']is not the pre-strip render the recovery path expects). New end-to-end regression testtest_time_travel_jump_recovery_version_is_current(a realWebsocketCommunicatorround-trip) plus helper-level unit tests inpython/djust/tests/test_recovery_version_staleness_1817.py; the_next_version_armedrender-send call-site count is pinned bytest_every_client_checked_send_path_uses_next_versioninpython/djust/tests/test_ws_send_version_1788.py, and the consolidated single_arm_recoverycall site is pinned bytest_arm_recovery_call_site_count_matches_known_send_pathsintests/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|safestored-XSS detection (#1821). A new template static-analysis check (severity WARNING) flags{{ <expr>.client_name|safe }}patterns in template files. An upload entry'sclient_nameis the attacker-controlled original filename, stored without sanitisation; auto-escaping is the only default protection, and|safebypasses 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 rejectnotclient_name/client_name_foo. HonoursDJUST_CONFIG['suppress_checks'](S007ordjust.S007). New cases inTestS007ClientNameSafeRegexandTestS007CheckIntegrationinpython/tests/test_checks.py.
Changed
- Modularized
checks.py(4,268 LOC) into achecks/package (#1822). Pure refactor, no behavior change: the monolithic system-checks module is split by check family intochecks/{utils,configuration,integrations,components,security,templates,accessibility,quality}.py, withchecks/__init__.pyfiring every@register("djust")for DjangoAppConfig.checksdiscovery and re-exporting every public + private symbol. All 13 registered checks, 72 check IDs, and the full public/private import surface are preserved; the entiretest_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 sopatch("djust.checks.<helper>")keeps working. New regression guard inpython/tests/test_checks_package_structure_1822.pypins 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 whereNis the parser's compile-time ordinal, so every loop iteration reused the same id. A later re-render that repositioned one boundary emitted aMoveSubtreewhose 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
MoveSubtreekeyed 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 theMoveSubtreefeature 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 incrates/djust_vdom/src/diff.rscompared ABSOLUTE child offsets (old_off + ob.openvsnew_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 spuriousMoveSubtree { id: "if-b-0" / "if-c-0" }ops the client could not pair (close marker not found), ~15/22 patches failed, and anhtml_recoverymorph dropped a row. The decision now keys on the boundary's position RELATIVE to its non-boundary siblings (a newnon_boundary_count_beforehelper over the existingexcludedmask) 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 reproducercrates/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 inpython/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→_findDjIfCloseMarker→close marker not found), and the Rustapply_allharness is not faithful to the JS client's document-wide depth-countingTreeWalker; new cases in thedj-if MoveSubtree — JS-client apply (#1826 follow-up)describe block (tests/js/dj_if_movesubtree_client_apply_1826.test.js) exercise the REALsrc/12-vdom-patch.jsagainst the correcteddiff_htmlpatch 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
versionstamped 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'sclientVdomVersion === data.version - 1check, forcing anhtml_update→request_htmlrecovery 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, andStreamingMixin.push_state), so a post-baseline-losshtml_updatestays 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 inpython/djust/tests/test_ws_send_version_1788.pypin 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
mountand sticky-childlive_redirectframes carry the current page URL, which the consumer fed straight intoRequestFactory.get(),resolve(), query-string concatenation, and log statements at two sites inpython/djust/websocket.pywithout validation.RequestFactorydoes not normalize..segments, so a craftedurlof../../admin/landed inrequest.pathas/..../admin/(path traversal; an auth/routing decision keyed onrequest.pathwould 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 andurlencodeconcatenation (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 inpython/djust/tests/test_security_mount_validation.pypin the empirical Django behavior, the helper's reject/preserve contract, the validated-url-is-safe-for-RequestFactoryend-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.cryptography46.0.7 → 49.0.0 (GHSA-537c-gmf6-5ccf),pyjwt2.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-multipart0.0.29 → 0.0.32 (GHSA-5rvq-cxj2-64vf, GHSA-6jv3-5f52-599m, GHSA-vffw-93wf-4j4q, GHSA-v9pg-7xvm-68hf),starlette1.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 Templatedata-*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 hexid="0x41"makeint()raise, so the original string is kept and type validation rejects the event (valid is False, handler not invoked) — there is no silent truncation to999; (bool) the dangerous case —active="true; DROP TABLE"— coerces toFalsebecause bool coercion is an allowlist (value.lower() in {"true","1","yes","on"}), NOTbool(non_empty_string), so the falsy-but-non-empty"false"/"0"are alsoFalse(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 coercedfloatmust 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_typesdecorator was added). The audited contract is documented inSECURITY_AUDIT.md(Type Coercion Contract table) and pinned byTestCoercionSecurityEdgeCases(11 characterization cases) inpython/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 owndj-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 adj-viewattribute, the rendered page ends up with a nested, duplicatedj-viewinside the wrapper — the child's client-side mount breaks and itsdj-click/dj-inputevents silently don't bind. This is a subtle footgun because normal page views requiredj-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/Vcategory, Warning) walksLiveViewsubclasses withsticky = True, resolves each one's template source, and scans for a<div ... dj-view ...>root tag — converting the silent footgun into amanage.py checkwarning. False-positive guards: onlysticky = Trueviews are inspected (normal page views, which legitimately declaredj-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 adj-viewdocumented inside a comment (e.g. the demo'saudio_player.htmlwrapper-example comment) is ignored; internal djust classes are skipped unless their module is a test/example. Suppress withDJUST_CONFIG = {'suppress_checks': ['V012']}. Documented on the sticky LiveViews guide and the system-checks reference. New regression cases inTestV012StickyChildOwnDjView(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 freshtype()-built sticky child), and the #1468 gate-off self-test. Empirically validated againstmanage.py checkon the demo project (0 false positives; fires on an injected real footgun).
Fixed
-
Flaky
test_total_wall_clock_is_max_not_summade deterministic (#1795). The parallel-lazy-render concurrency test asserted a wall-clock ratio (parallel < serial/2); under fullmake test -n autoCPU saturation the 3 concurrent thunks couldn't get dedicated cores and the speedup ratio drifted past 0.5, false-failing the releasemake 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 satisfiesmax(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_recoveryno longer resets an embedded sticky child to itsmount()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 triggeredhtml_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 freshchild_cls()+mount()on every parent render (the two pre-existing escape hatches —_sticky_preservedauto-reattach and session-backedrestore_sticky_child_state— are inert in the default config, the latter gated behindenable_state_snapshot=True), so every parent re-render and every_recovery_htmlsnapshot 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 theStickyChildRegistry) instead of mounting fresh, independent ofenable_state_snapshotand composing with (not bypassing) the existing hatches. The shared_render_sticky_child_htmlhelper keeps the fresh-mount and reuse paths byte-identical (parallel-path-drift guard, #1646). (b2)(ii) recovery freshness —handle_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 scopedembedded_updateand deliberately does not re-arm recovery, so the cached_recovery_htmlwas 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 prerenderskipMountHtmlmorph keysmorphChildrenbynode.id, but the sticky wrapper (<div dj-view dj-sticky-view dj-sticky-root data-djust-embedded=...>) has noidand only aligns positionally, so once a preceding sibling count diverges the serverdj-idwas 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 serverdj-idonto each live wrapper matched by its stabledata-djust-embeddedvalue (the45-child-view.jsselector). Regression coverage: 5 WebsocketCommunicator end-to-end cases inpython/djust/tests/test_sticky_child_recovery_1813.py(default config, gate-off verified for both b1 and b2) and 7 cases intests/js/ws-mount-prerender-divergence-1813-sticky-djid.test.js(gate-off verified for the dj-id stamp). -
A worktree
git pushnow runs the pre-push pytest suite against the worktree's Python source, not the main checkout's (#1810). #1796 fixed interpreter resolution from agit worktree, but the editablematurin developinstall binds Python imports to the main checkout via a plaindjust.pththat appends<main>/pythontosys.path— so agit pushfrom 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) meansPYTHONPATH— which Python inserts before.pthprocessing — wins when it points at the worktree'spython/, while a bare worktree import resolves the main tree (a sentinel added to the worktree's__init__.pywas invisible without the prepend, visible with it). Fix: a newscripts/run-with-venv-python.sh --worktree-pythonpathmode emits the current worktree'spython/dir to prepend toPYTHONPATH(a no-op — empty output — in the main checkout or outside a git tree) and symlinks the matching compiled_rust.<cache_tag>-*.sofrom the main checkout into the worktree'spython/djust/soimport djust._rustkeeps resolving once the Python source is shadowed (the.sois gitignored, so the symlink never appears ingit status). The pre-pushpytesthook in.pre-commit-config.yamlnow prepends this path. Caveat (documented inCONTRIBUTING.md): this shadows only Python source — Rust (djust._rust) changes still needmaturin developrun against the worktree; CI remains authoritative. New cases inTestWorktreePythonpath(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.sosymlink, and an entry-line source-pin on the config wiring (gate-off: reverting the entry line fails it). -
System check
djust.T004no longer flagsdocument.addEventListenerfor djust events that are dispatched ondocument, and now honorssuppress_checks(#1809). T004 (document.addEventListener('djust:...')→ usewindow) had two defects. (1) False positive that broke correct code: it assumed alldjust:events dispatch onwindow, but djust dispatches a whole family ondocument—djust: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'sdocument.dispatchEvent(new CustomEvent('djust:...'))sites instatic/djust/client.js/src/03-websocket.js/18-navigation.js/40-dj-layout.js). Listening for those ondocumentis correct, yet T004 flagged them and told the user to switch towindow, which would break the listener (it would never fire). (2) Unsuppressible: the emission loop never called_is_check_suppressed, soDJUST_CONFIG = {"suppress_checks": ["T004"]}was a no-op. Fix:_DOC_DJUST_EVENT_REnow 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")(mirrorsT002/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 inTestT004DocumentDispatchedEvents(navigate-end + every document-dispatched event not flagged; window-dispatchedpush_eventstill flagged) andTestT004Suppress(fires without suppression; silenced via both short and qualified IDs) inpython/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 indocs/system-checks.mdanddocs/guides/error-codes.md. -
Embedded sticky-child (
{% live_render "...View" sticky=True %}) events now produce a patch instead of a barenoop(#1802). A sticky / embedded child widget'sdj-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 realWebsocketCommunicator, confirming thenoop): the auto-skip-render block inLiveViewConsumer.handle_eventsnapshotted public assigns onself.view_instance(the PARENT) both before and after the handler. Embedded-child events route viaview_idsotarget_viewis the CHILD; the handler mutates the child, leaving the parent's assigns unchanged →pre_assigns == post_assigns→skip_render = True→_send_noopfired BEFORE the embedded-child render branch (which builds the scopedembedded_updateframe) could run. Fix (Python-only): bindchange_target = target_viewand take the pre/post assigns + push-command identity snapshots — and read_skip_render/_force_full_html/_pending_push_events, write_changed_keys— againstchange_target. For a top-level eventtarget_view IS self.view_instance, so the common path is unchanged; for an embedded child the mutation is now detected and the existingembedded_updateframe (full child HTML, applied client-side via45-child-view.js'shandleEmbeddedUpdateagainst[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(realWebsocketCommunicator: mounts a parent embedding asticky=TrueNotificationsView, firesdismisswithview_idin params, asserts anembedded_updatereflecting the mutated state — notnoop), a standalone control, and achange_targetsource pin inpython/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.pyandtest_sticky_http_get_1784.pystill pass. -
App-template dir collector uses
is_dir()to matchDjustTemplateBackend(#1805).utils._get_template_dirs_cached(the cached APP_DIRS collector used by the shell render) guarded each app'stemplatespath withexists(), while djust's ownDjustTemplateBackend._get_template_dirs(template/backend.py) usesis_dir(). The two parallel-path collectors disagreed: withexists(), a plain file literally namedtemplates(no extension) would be wrongly added to the template search dirs. Switched the cached helper tois_dir()so both reject non-directories identically (pre-existing tech-debt surfaced in the #1804/#1801 review; harmless in practice). New cases inTestCollectorIsDirGuardpin that a file namedtemplatesis excluded while a realtemplates/directory is still collected (gate-off verified against the pre-fixexists()guard). Also documents that thetest_resolution_failure_is_logged_not_silentmonkeypatch target depends on thefrom djust._rust import resolve_template_inheritanceimport staying insideget_template(). -
{% extends %}pages now keep the base template's<head>on the initial HTTP GET (#1801). ALiveViewwhose template{% extends "base.html" %}served an initial GET containing only thedj-rootsubtree (no<!doctype>/<html>/<head>/<title>/<style>from the base template), so every template-inheritance page — including the untoucheddjust newscaffold — 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 onlydjango.template.backends.django.DjangoTemplates. The scaffold (and any project) configuring djust's own backenddjust.template.backend.DjustTemplateBackendwithAPP_DIRS=Truehad its app-template directories silently dropped, soresolve_template_inheritanceraisedRuntimeError: Template error: Template not found— which was swallowed by a broadexcept Exceptionthat logged only at DEBUG and setself._full_template = None.render_full_templatethen fell through to itselse(return self.render(request)) → the baredj-rootfragment with no shell/head. Two-part fix: (1) the APP_DIRS dir-collection now recognizes the djust backend(s) via a sharedutils._APP_DIRS_TEMPLATE_BACKENDSset, andget_template()resolves dirs through the singleget_template_dirs()helper it already shared withrender_full_templatestep 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 theresolve_template_inheritancecall (the legitimate raw-template fallback for genuinely-unresolvable templates) and now logs at WARNING — post-resolution VDOM extraction/strip is moved outside thetryso an unexpected framework error surfaces instead of silently degrading to fragment-only. Verified against a freshdjust new demo --no-setupGET: 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, andtest_resolution_failure_is_logged_not_silentintests/integration/test_extends_head_initial_get_1801.pydrive the realas_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 existingtest_sticky_http_get_1784.py/ SSR-parity suites are unaffected. -
Serial-order test pollution that broke
test_checksS005 +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 parallelmake test -n autogate (which isolates per-worker and never collectspython/djust/tests/):test_checks.py::TestS005UnauthenticatedViews::test_s005_suppressed_with_login_required_false, andtest_client_config_tag.py::test_auto_navigate_meta_emitted_when_enabled/::..._engines_identical. Two independent polluters, neither asettings.DATABASES/LIVEVIEW_CONFIGleak (the reported hypothesis): (1)python/djust/tests/test_ws_auth_close_socket.pydefines a module-levelLiveViewsubclass_PublicViewwith nologin_requiredand exposed state (self.ok), so it permanently joinsLiveView.__subclasses__()and thedjust.S005check fired on it ("PublicView" in msg) for any later test asserting the S005 result set — fixed by marking itlogin_required = False("intentionally public"), which is also its actual contract; (2)tests/unit/test_ws_compression_config.py::_fresh_configcalledimportlib.reload(djust.config), rebindingdjust.config.configto a new singleton while everyfrom djust.config import configconsumer (notablydjust.templatetags.live_tags) kept the old reference — so theauto_navigatetests reset the new singleton whilelive_tagsread the stale one and never emitted the<meta>— fixed by re-reading settings viaconfig.reset()on the shared singleton (same effect, no orphaning) plus an autouse teardown fixture. Verified with three consecutive clean serialpytest tests/ python/tests/ python/djust/tests/runs (7692 passed each). Test-only changes; no framework behavior change. -
Pre-push hook (and
maketargets) now resolve the project venv from any git worktree (#1796). The native pre-push hook entries in.pre-commit-config.yaml— and ~31maketargets — hardcoded.venv/bin/pythonrelative to the current working directory. Agit worktree(e.g. the ones pipeline-drain subagents create under.claude/worktrees/) has no.venvof its own, so the hook failed withbash: .venv/bin/python: No such file or directory(exit 127), forcinggit push --no-verifyand skipping the real gates. Newscripts/run-with-venv-python.shresolves the interpreter relative to the MAIN working tree root (dirnameof the absolute--git-common-dir, which points at<main-root>/.gitfor both the main checkout and every linked worktree), falling back touv run pythonthenpython3on PATH when no.venvexists (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 andmake testboth run from any worktree instead of erroring. 6 regression cases intests/test_run_with_venv_python.py(realgit worktreeresolution, main-checkout no-regression,python3fallback, 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 newscaffold is now warning-clean and the deprecatedcli.py startprojecttwin no longer ships broken templates (#1791, follow-up to #1787/#1790). After #1790 fixed the boot blockers, a freshdjust newproject passedmanage.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): C012 —base.htmlloads{% load live_tags %}and uses{% djust_client_config %}instead of a manual<script src=".../client.js">tag (the LiveView post-processing pipeline auto-injectsclient.js, so a manual tag double-loads); S005 — the in-memory demo view (and the--with-dbdemo view) declareslogin_required = Falseto acknowledge it is an intentionally-public to-do list with no per-user data; Y001/Y003 —index.html's icon-only toggle/delete buttons getaria-labels and the search/add-item inputs getaria-labels; A030 —django.contrib.adminis now opt-in (the default in-memory scaffold omits it, eliminating the brute-force-protection warning and the admin-only secondDjangoTemplatesbackend), while--with-db/--from-schemastill wire admin + its template backend + the/admin/URL, where A030 fires by design as correct security guidance. Separately, the deprecateddjust startprojectcommand carried its own divergent project templates that still shipped the brokenapplication = live_session()ASGI app and the droppeddaphnestack (the same #1787 bug); rather than maintain a second drift-prone template set (parallel-path-drift),cmd_startprojectnow prints a deprecation notice and delegates to the canonicalgenerate_project(), producing the same warning-clean, uvicorn-booting project. Regression coverage:TestScaffoldWarningClean1791(tests/integration/test_scaffold_boot_1787.py) asserts a fresh scaffold'smanage.py checkemits zeroWARNINGSand none ofdjust.C012/S005/Y001/Y003/A030; the rewrittenTestStartProjectDeprecated(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
LiveViewwhosemount()assigned only JSON-serializable scalars still emitted, on every render/event, a flood ofserializationwarnings naming the request and the standard context-processor outputs (ASGIRequest/WSGIRequest, authPermWrapper, messagesFallbackStorage,SimpleLazyObject/UserLazyObject) — values the view never assigns toself. It also bloated the_prev_context_refschange-detection fingerprint (thedict '_prev_context_refs' has N keys — fingerprint truncatedwarning) and inflated the state written to the Redis state backend. Root cause:_sync_state_to_rustfolds 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 freshid()each cycle) they flowed throughnormalize_django_value(one warning per value) and into the_prev_context_refsfingerprint. Fix:_apply_context_processorsnow records the keys it added onself._context_processor_keys;_sync_state_to_rustexcludes those keys (plusrequest) from the change-detection fingerprint and theset_changed_keysskip set, and skips the non-serializable ones from theupdate_state/normalize_django_valuewarning 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 inTestContextProcessorStateLeak1786(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_refsand 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 thelive_rendertag) is rendered through the Rust engine with a JSON-serialized context that structurally cannot carry the live parentLiveViewobject; the tag looked the parent up viacontext.get("view")/context.get("self")(both absent) and raisedTemplateSyntaxError: {% 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) indjust.templatetags.live_tags; bothrender_full_templateandrender_with_diffregisterselfas the active parent for the duration of their Rust render (both re-run the tag through the Rust engine on the GET path), andlive_renderfalls back to the thread-local when the render context has noview/selfand to the parent's liverequestwhen the JSON-serialized request was stringified. The WS / Django-engine paths carry a realviewin context, so the fallback is inert for them and existing sticky preservation acrosslive_redirectis unchanged.sticky_demo(the only embedded-{% live_render %}example app, and the only demo app not wired into the demo project) is now wired intodemo_projecturls +INSTALLED_APPSso the initial-GET server-render path is exercised end-to-end — the gap that let the bug ship unexercised. Regression coverage intests/integration/test_sticky_http_get_1784.py(test_sticky_live_render_http_get_returns_200,test_sticky_live_render_http_get_includes_child_html, andtest_sticky_demo_dashboard_http_get_200) drives the realas_view()GET path for both the inline-template and template-inheritance branches; all three fail against the unfixedrender_full_template. -
djust newnow scaffolds a project that actually boots (#1787). The generatedasgi.pydidapplication = live_session(), butlive_session(prefix, patterns, ...)is a URL-pattern helper (returnsList[URLPattern]), not an ASGI app — importing the scaffoldedasgi.pyraisedTypeError: live_session() missing 2 required positional arguments, somake devcrashed immediately. The asgi template now lifts thedemo_project/asgi.pypattern: aProtocolTypeRouterwhose"http"isASGIStaticFilesHandler(get_asgi_application())(servesclient.js/CSS under uvicorn with no WhiteNoise) and whose"websocket"isAllowedHostsOriginValidator(AuthMiddlewareStack(URLRouter([path("ws/live/", LiveViewConsumer.as_asgi())]))), withget_asgi_application()called before the channels/djust imports so the app registry is populated. The dev stack moves off daphne to uvicorn: the Makefiledevtarget now runsuvicorn <name>.asgi:application --host 127.0.0.1 --port 8000 --reload,requirements.txtdropsdaphne>=4.0foruvicorn[standard]>=0.30, andINSTALLED_APPSlists"channels"instead of"daphne". Separately, a freshly-scaffolded project failedmanage.py check(which blocksmigrate) on two ERRORS:djust.A014(the scaffold ran in production mode —settings.pyreados.environbut never loaded the generated.env, soDEBUGwas False and thedjango-insecure-key was flagged) andadmin.E403(noDjangoTemplatesbackend for the admin). settings.py now ships a dependency-free.envloader (os.environ.setdefaultperKEY=VALUEline, comments/blanks skipped) and the scaffolder writes a working.env(DEBUG=True + a realSECRET_KEY, gitignored) so dev mode is on out of the box; a secondDjangoTemplatesTEMPLATES backend satisfies the admin. Thedjango-insecure-prefix is retained as the production marker (A014 still fires in realDEBUG=Falsedeploys).manage.py checknow 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 intest_scaffold_boot_1787.py(test_scaffold_asgi_imports_and_check_passesasserts the generatedasgi.pyimports +applicationis a callable ASGI app, andmanage.py checkexits 0; fails against the pre-#1787 templates). -
WebSocket recovery no longer forces a full page reload on the
html_updatefallback (#1785). When a LiveView event's VDOM diff returns no patches and the server sends a full-HTMLhtml_updateframe (the DJE-053 fallback), it now arms on-demand recovery — matching the patches path. Previously thehtml_updatebranch inhandle_eventskipped_arm_recovery, so a client that subsequently requested recovery (e.g. after a VDOM version mismatch on the full-HTML frame) receivedRecovery HTML unavailable — the server may have restartedand reloaded the whole page instead of morphing. Surfaced by a multi-replica djust.org/insights/page reloading on every time-range switch. Added aWebsocketCommunicatorregression test (TestWSRecoveryHtmlUpdate-style, intest_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). Withdj-navigateyou annotate each link;auto_navigategoes 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 viaLIVEVIEW_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 delegateddocumentlistener. It is deliberately conservative — a link falls through to a normal browser navigation on a modifier/middle click, atargetother than_self, adownloadattr,rel="external", adata-no-navigateancestor, 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 uselive_patch(state-preserving); cross-view useslive_redirect. Nativedj-navigate/auto_navigateis positioned as djust's canonical SPA model;turbonav-integration.mdis reframed as interop (#1735). NewTestRouteMapAuthFilter-adjacent JS suite (tests/js/auto_navigate_1734.test.js, 15 cases incl. the full opt-out matrix) +TestClientConfigemit tests.djust deploypreflight "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-dirnow run a non-blocking static preflight (_run_deploy_doctor) over the resolved settings module (located viamanage.py'sDJANGO_SETTINGS_MODULE, falling back to the shallowestsettings.py) and print warnings to stderr — never errors, the deploy still proceeds — when it finds: a hardcoded literalSECRET_KEY(dev key on a public host), anALLOWED_HOSTSliteral not read from env (→DisallowedHost400), aDATABASESblock that never consultsDATABASE_URL(→OperationalError: readonly databaseon the read-only app rootfs), or a sqliteENGINE(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 inTestDeployDoctor.djust deployshows "rolling out" instead of "active" while a blue/green rollout is still serving stale code (#1761). djustlive'sdeployment_statusendpoint now returns an additiveserving_currentboolean (djustlive #517) —Falsewhile a new rootfs is built and marked current but the old placement is still serving the env URL during cutover. Thedeploy-dirpoll loop now consumes it: while the deployment row readsactive/deployingbutserving_currentisFalse, the CLI printsStatus: rolling out (new version built; old version still serving — waiting for cutover)and keeps polling, instead of reportingactiveand printing the URL on stale code (which led users to re-test against the old rootfs). A missing field is treated asTrue(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 inTestPollDisplay.
Security
-
Documented
LIVEVIEW_ALLOWED_MODULESas 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 anyLiveViewby path (enumeration — not an auth bypass, since per-view auth still gates). Added guidance todocs/guides/security.md+docs/SECURITY_GUIDELINES.mdto set it to your app's module prefixes in prod (djust.V005flags 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 declareslogin_required/permission_required,handle_eventre-resolves the user from the session (channels.auth.get_user) and re-runs the view's auth check, sending a navigate redirect +close(4403)+ clearingview_instanceon 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 torequest.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_requiredmount redirect now closes the socket instead of leaving it open (threat model T1/T2).handle_mountsent a{"type":"navigate"}redirect frame on thelogin_required(andon_mount-hook) failure branches but did not close the socket — only thePermissionDeniedbranch closed it (4403). The view never mounted yetview_instancestayed set, andhandle_eventnever re-checks auth, so a raw WebSocket client that ignored the navigate frame could send{"type":"event"}messages and reach@event_handlermethods 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 andhandle_live_redirect_mount(which delegates tohandle_mount). Both redirect branches now send the navigate frame, thenclose(code=4403)and clearview_instance, mirroring thePermissionDeniedbranch; public/authorized mounts are unchanged. AWebsocketCommunicatorreproducer (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_MODULESis default-open), tracked as follow-ups — is documented indocs/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._routeMapwas built bybuild_route_map_from_urlconfwith 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 dottedmodule.QualNameview-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 whoseLiveViewdeclareslogin_required/permission_required(or whose callback islogin_required(as_view())-wrapped, or which uses Django'sLoginRequiredMixin/PermissionRequiredMixin) unlessrequest.usersatisfies 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). NewTestRouteMapAuthFiltercases + 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.0with no backport: an out-of-bounds read innth/nth_backforPyList/PyTupleiterators (High), and a missingSyncbound onPyCFunction::new_closureclosures (Moderate). No Dependabot PR existed for either. Bumpspyo3andpyo3-async-runtimes0.25 → 0.29 and migrates the FFI layer across the 0.26–0.29 breaking changes:Python::with_gil→Python::attachandPython::allow_threads→Python::detach(GIL attach/detach rename), the removedPyObjectalias →Py<PyAny>,Bound::downcast→Bound::cast, the reshaped two-lifetimeFromPyObjecttrait (extract(Borrowed<…>)replacingextract_bound(&Bound<…>)), and the now-opt-inCloneauto-FromPyObjecton#[pyclass]types (PyVNodeopts in,SupervisorStatsPyopts 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
DATABASESbuilt from individualos.environvars (#1768, follow-up to #1760). The #1760DATABASEScheck warned whenever the settings text mentioned neitherDATABASE_URLnordj_database_url, so a perfectly environment-driven config assembled from individualos.environ['DB_NAME']/['DB_HOST']/… vars got a spurious "doesn't consultDATABASE_URL" warning. The env-read detection is now widened — the canonicalDATABASE_URL/dj_database_urltokens still count anywhere, and other env reads (os.environ,os.getenv, python-decoupleconfig()/env()) count within theDATABASESassignment region (a new brace-balancing_databases_region()scopes it, so an unrelatedSECRET_KEY = os.environ[...]elsewhere can't mask a genuinely hardcoded DB block). 2 new cases inTestDeployDoctor. -
Log the swallowed theme-context cache-write skip instead of silently passing (#2380). CodeQL flagged
except (AttributeError, TypeError): passintheme_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 alogger.debugnaming the request type and the exception, satisfying both CodeQL and the project's no-bare-except rule. Behavior is otherwise unchanged. New case inTestThemeContextCache. -
djust deploynow respects.gitignorewhen building the deploy tarball, and warns before oversized uploads (#1759)._create_tarballpreviously walked the source tree using a hardcodedEXCLUDE_*list, ignoring the project's.gitignore. Non-standard names — a.venv-dev/virtualenv carrying a 115 MB Playwrightnodebinary,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_tarballtakes its file list fromgit ls-files --cached --others --exclude-standard(tracked ∪ untracked, minus ignored), so the project's.gitignoreis the source of truth. Non-git directories fall back to the existingos.walkpath unchanged. TheEXCLUDE_*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_warningprints 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.gitignoreinstead of hitting a raw nginx 413 page. 6 new regression cases inpython/tests/test_deploy_cli.py.
[1.0.3] - 2026-06-07
Added
dj-navigatekeepsaria-current="page"in sync across SPA navigation (#1756). A persistent nav usually lives outside[dj-root]; sincedj-navigateswaps only the[dj-root]subtree, a server-rendered active-link highlight previously stayed on the page you navigated from. The client now setsaria-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-origindj-navigatetargets are never marked current, and an app-authoredaria-currentof a different value is left untouched. Style the active link witha[dj-navigate][aria-current="page"]. (Syncingdocument.titleand the[dj-root]dj-viewattribute across SPA nav remain tracked in #1756.)
Fixed
-
render_full_templateejected page content outside[dj-root]for multi-line<div>tags, leaking it acrossdj-navigate(#1749). Thedj-rootregion'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 thedj-rootregion 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). Becausedj-navigateswaps 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<divfollowed by any tag-boundary char (whitespace,>,/). Same family as #1746. -
dj-rootboundary 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 consolidatedrender_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_templatedouble-rendered the whole page when a child template merely mentioneddj-root/dj-viewas 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, sorender_full_templatenested the entire page (two<!DOCTYPE>/two<footer>, truncated inline<script>, brokendj-navigatemorphs). Replaced the substring check with the anchored-attribute regexes_DJ_ROOT_RE/_DJ_VIEW_REalready 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 becauseexamples/demo_projectdidn't exercise these paths end-to-end. Adds two plaindjust.LiveViewpages (NavDemoPageAView/NavDemoPageBViewat/demos/nav-a/and/demos/nav-b/) linked bydj-navigate— confirming the #1733 dogfood that SPA cross-view nav needs NOlive_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 aDemoWidgetdj-hook(canvas,dj-update="ignore") registered once in the persistent shell per the #1738 pattern. Newtests/playwright/test_nav_hooks.py(wired into theplaywright-testsCI job) asserts: awindowsentinel survives navigation andlocation.pathnamechanges (#1733 SPA nav, not a full reload); aMutationObserveron the[dj-view]root records zero direct-child remove/re-add during first-load hydration (#1737 no-flash); and the hook'smounted()marker is set on initial load AND a freshmounted()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-navigatepreviously SPA-navigated only if the developer manually wiredlive_session()and emittedget_route_map_script(); with no route map it silently full-reloaded. Nowdjust.routing.build_route_map_from_urlconf()walks the Django URLconf (descendinginclude()resolvers) and collects every route whose callback resolves to aLiveViewsubclass — handling bothcallback.view_classandlogin_required-wrappedcallback.__wrapped__.view_class, converting<int:id>→:id, and applying theFORCE_SCRIPT_NAMEsub-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). Nolive_session()required. New system checkdjust.T016warns whendj-navigateappears in templates but the derived route map is empty (suppressible viaDJUST_CONFIG['suppress_checks']).
Changed
{% djust_client_config %}now takes the template context (#1733). The tag becametakes_context=Trueso it can readrequest.csp_noncefor 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 withlive_session()entries (#1733). Behavior change: the emittedwindow.djust._routeMapnow includes auto-derived LiveView routes in addition to anylive_session()registrations (idempotent union). Apps that calledget_route_map_script()with nolive_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 0ceiling 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 inpython/djust/static/djust/*.jswith no ceiling, so the count could silently grow. All 33 are now resolved: 1prefer-constand 1no-var(single-assignment globals), 9no-unused-vars(8 unused caught errors → barecatch {}, 1 unused arg →_params), and 22security/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-linewith a per-site justification (no blanket config disable; no site was a real injection risk). The--max-warnings 0ceiling is re-added to BOTH gate paths — the.pre-commit-config.yamleslint hook and thepackage.jsonlintscript (used by the Pre-Release Security Audit workflow) — so the warning count can only go down. Nosrc/module changed, so theclient.js/debug-panel.jsbundles rebuild byte-identically; all 1665 JS tests pass. - CI promotes the demo
djust_checkdogfood to a BLOCKINGdemo-checksjob (#1713, CI infra — completes #1708, applies CLAUDE.md rc4 retro finding #3). The dogfood added in #1708 ran as acontinue-on-errorstep inside the non-blockingplaywright-testsjob, 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 dedicateddemo-checksjob WITHOUTcontinue-on-errorand wired into thetest-summaryaggregate gate (added toneeds:and the blocking success condition alongside rust/python/js/security-tests). The dogfood step is removed fromplaywright-tests, which stays non-blocking and decoupled from the demo check (its ownmigratestep is retained for the dev server it starts). The wrapperscripts/ci_djust_check_demo.pyis refactored to extract an importableevaluate(parsed_json)decision function (CLI behavior ofmain()unchanged), and a new unit test (tests/test_ci_djust_check_demo.py) feeds SYNTHETICdjust_check --jsonpayloads 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-attrT001/T014/T015ID-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_contextnow request-scope memoizes its four tag-body renders (#1727, follow-up to #1722's Stage 11 PERF-1). #1722 madetheme_contextrun 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_renderoutputs 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_outputskeys 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_jsderives from thecookie_namespaceconfig, not the request), but request-scoped caching cannot leak a future per-request value across requests; on the WS pathrequestis a long-lived instance attr set once inhandle_connect, so the cache spans all events of a connection and invalidates on a theme switch. New regression tests intest_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_infoandTestSuppressChecks::test_no_suppress_by_default) failed deterministically under the cross-dir orderpytest python/djust/tests/ python/tests/test_checks.py(and intermittently in CI shards) while passing in isolation. Root cause: theblock_watchdogfixture intest_dev_server_watchdog_missing.pyre-importsdjust.checksto exercise the no-watchdog import path (#994); a re-import rebinds bothsys.modules["djust.checks"]AND the parent-package attributedjust.checks, but the fixture restored onlysys.modules. The two then pointed at different module objects, so a downstreammonkeypatch.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 alongsidesys.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-hydrationmorphChildrenrebuild 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 thedj-viewroot when no literaldj-rootattribute 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 Rustrender_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 thedj-idattrs the client stamps onto the prerender DOM per #1610), so the firstmorphChildrenis a no-op.<pre>/<code>/<textarea>internal whitespace anddj-ifboundary 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 aglobalThis.djust-published function declared inside module A's IIFE is not visible as a bare name in module B — the sameReferenceError-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-guardelse {}block, else null for program scope), and a bare reference is flagged iff it falls outside that span. Program-scope declarations (e.g.maybeDeferRemovalin42-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 intests/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 raisedRuntimeError: 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 thinTagHandlerbridge for each documented theme tag viadjust._rust.register_tag_handlerinDjustThemingConfig.ready(); each handler delegates to the sametheme_tags.py@register.simple_tagbody 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 intest_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 inrender_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_rustnow 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-injectionin03-websocket.js(server-sent view name) anddebug/07-tab-state.js(null-prototype clone with UNSAFE_KEYS filter);js/xssin live_redirect path (target validated to same-origin path). Rebuildsclient.jsanddebug-panel.jsto pick up the suppressions. - Fix
UploadWriter.write_chunkbase class signature to accept optionalchunk_index: int = 0— resolves CodeQLpy/inheritance/incorrect-overridden-signature(#2190); callers already guard via_supports_chunk_indexintrospection. - SSR→hydration no longer replaces the
dj-viewroot's top-level children wholesale on the first WebSocket hydration (#1724). Root cause was whitespace text-node misalignment inmorphChildren: 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.morphChildrennow 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 afterdj-navigateSPA 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 inmounted()(fires on hydration AND SPA patch-insert), and dispose indestroyed(); explains whydj-update="ignore"keeps the VDOM from fighting the library's own DOM mutations. Cross-linked from the navigation guide'sdj-navigatesection 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--manglerenamed cross-moduleapplyPatches→ie, fixed with--keep-fnames; #1688/#1690 bareapplyPatchesreference in45-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 concatenatespython/djust/static/djust/src/[0-9]*.js; modules00-20sit INSIDE the double-load-guardelse {}block (sofunction foo() {}declared there is block-scoped), while modules22-51run 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 viaglobalThis.djust.Xis out of scope even unminified (thetypeofguard silently returns"undefined"and the feature no-ops) and throwsReferenceErrorunder terser-minified bundles. The check reuses thecheck-bundle-init-order.mjswalker model (in-memory bundle build, acorn parse, line→module map), builds the djust-published function set, computes the guardelse {}scope span, and flags any bare cross-scope reference (browser globals anddjust.Xmember 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 CIjavascript-testsjob (which also gains the init-order check, previously pre-commit-only). Pinned by 6 cases intests/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 newdocs/website/guides/migrating-from-django-tenants.mdis the step-by-step migration recipe. Covers: (1) a mental-model translation table (schema →tenant_idcolumn;TenantMainMiddleware→djust.tenants.middleware.TenantMiddleware;SHARED_APPS/TENANT_APPS→ one unifiedINSTALLED_APPS;Domainmodel →DJUST_CONFIG['TENANT_RESOLVER']); (2) the schema-to-row data-migration recipe (nullable-add →INSERT ... SELECTper schema → tighten, with explicit handling of FK remapping, cross-tenant unique constraints → composite(tenant_id, …), sequences, and indexes); (3) code migration (addtenant_id; filter via explicittenant_id,TenantScopedMixin.get_tenant_queryset(), orTenantQuerySet.as_manager(tenant_field=…); swap middleware); (4) settings diff (collapse the app split, swap middleware, dropDATABASE_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 realdjust.tenantsmodules (notably: the scoped-queryset helper isget_tenant_queryset(), nottenant_queryset). Linked from_config.yaml,index.md, and the Multi-Tenant guide. - New
T015system check — detects the legacydata-djust-root/data-djust-viewroot attributes (#1602). Pre-1.0 templates declared the LiveView root withdata-djust-root/data-djust-view; djust 1.0 renamed these todj-root/dj-view(thedata-prefix is no longer required). When a template still uses the old spelling, the genericT012("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.T015scans user template files and emits a Warning that names the rename explicitly, with afix_hintper offending occurrence (file:line). The match is scoped via a negative-lookahead to exactlydata-djust-root/data-djust-view, so otherdata-djust-*attributes (data-djust-embedded,data-djust-activity,data-djust-view-model, …) never false-match. Suppressible viaDJUST_CONFIG = {"suppress_checks": ["T015"]}. Scope: static check only — the runtime does not accept the legacy attributes (a separate change). New cases inTestT015LegacyRootAttrs(empirical-canary + gate-off verified; dogfooded clean against the demo project). DJUST_NOTIFY_DATABASE_URL— optional dedicated DSN for thedjust.dbLISTEN connection (#1687).db.notifications._build_dsn()previously always derived the long-livedLISTENAsyncConnectionDSN fromsettings.DATABASES['default'], so the listener could not be isolated from the request-path connection pool (downstream djustlive #380: pgbouncer session-pool saturation →/healthhangs). A new optionalDJUST_NOTIFY_DATABASE_URLsetting (also honored as an environment variable of the same name) supplies aDATABASE_URL-style override (postgres://user:pass@host:port/dbname) that is preferred BEFORE theDATABASES['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 raisesDatabaseNotificationNotSupported), and the override URL/password is never logged. New_dsn_from_url()helper parses the URL viaurllib.parse(no new dependency). Pinned by 7 cases inTestBuildDsnOverride(gate-off verified).DJUST_NOTIFY_DATABASE_URLnow 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.hostprecedence: a?host=query item REPLACES the URL netloc host (so the output carries exactly onehostkey — the deterministic unix-socket behavior; the netloc host becomes an ignored placeholder). Credential safety: unknown query keys are silently dropped, anduser/password/dbnameare 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 usesurllib.parseonly (no new dependency); the URL/DSN/password is never logged. New cases inTestDsnQueryParams(gate-off verified).
Changed
- CI now dogfoods
djust_checkagainst the demo project (#1708, CI infra — enforces CLAUDE.md #1060). #1683 shipped dead@clickbuttons to the 1.0 GA demo even though theT001system check existed — because the demo templates were never run throughdjust_checkin CI. A new step in theplaywright-testsjob runsscripts/ci_djust_check_demo.py, a wrapper aroundmanage.py djust_check --json. The wrapper is necessary becausedjust_checkitself 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 classesT001/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 reportT001and exit 1; the clean demo exits 0. The step inherits the job'scontinue-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.pynow scansdocs/website/guides/*.mdfor 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 hallucinateddjust.tenantssymbols 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 useprint()in demo examples, so the style verdict is out of scope). New--guides-dir/--no-guidesflags (guides scanned by default; an explicit missing--guides-diris a usage error, exit 2). Wired into CI (test.yml) and the pre-commit hook (itsfiles: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_theming→djust.themingin components.md;djust.live_view.state→djust.decorators.statein state-primitives.md;djust.uploads.stores→djust.uploads.storagein uploads.md) and 6 intentionally-illustrative blocks (externalcelery, placeholderyourapp.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 inTestCheckGuides(gate-off + submodule-fallback regression, empirical-canary verified: re-introducingfrom djust.tenants import tenant_querysetmakes the checker exit 1 and name the symbol). No framework behavior change — CI/docs only.- C014's
hintandfix_hintnow link the new django-tenants migration guide (#1559). The check (django-tenants + ASGI withoutTENANT_LIMIT_SET_CALLS) already led with the migrate-to-djust.tenantsrecommendation and the strategy-decision guide (multi-tenant.md); both thehintandfix_hintnow also point atdocs/website/guides/migrating-from-django-tenants.mdfor the step-by-step recipe. No logic change — same trigger conditions, same suppression (DJUST_CONFIG = {'suppress_checks': ['C014']}), and all existingmulti-tenant.md/djust.tenants/TENANT_LIMIT_SET_CALLShint 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(…)incrates/djust_vdom/src/patch.rs(descending removes viastd::cmp::Reverse, ascending inserts plain) andcrates/djust_vdom/src/lib.rs(two descending offset sorts viaReverse), and a collapsible nestediffolded into thematcharm guard incrates/djust_templates/src/parser.rs. Two eslint errors resolved: the redundant'use strict'inside the IIFE-modulejs/pwa.jsis removed (rulestrict), and the XSS-sanitizer denylist matchval.startsWith('javascript:')insecurity.jscarries an explanatory// eslint-disable-next-line no-script-url(the check itself is unchanged —no-script-urlwas a false positive flagging a denylist MATCH, not a script-URL USE). (2) De-noise CI:.github/workflows/pre-release-security-audit.ymlnow sets workflow-levelCARGO_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.mdartifacts. (3) Gate eslint on errors: the JS-scan eslint step dropped|| trueforset -o pipefail+ captured-exit, so a real severity-2 regression (e.g. an XSS /no-script-urlerror) 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 makesnpm run lintexit 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 whencreate_issue=falsewas passed — GitHub Actions coerces a booleanfalseand''both to0, sofalse == ''istrue; dropped the== ''clause (inputs.create_issue == trueis 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 thesecurity-audit-reportartifact, 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-existentdjust.tenantssymbols (#1699). The guide cited several APIs that do not exist, so copy-pasted examples wouldImportError/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 ismixin, 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 aRESOLVER_REGISTRYkey ('subdomain'/'path'/'header'/'session'/'custom', or a list for chained resolution); the per-strategyDJUST_TENANT_CONFIGnested dicts were folded into flatDJUST_CONFIGkeys (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→ realget_tenant_object/create_for_tenant), the Testing section (which imported a non-existentdjust.tenants.testmodule — rewritten to useset_current_tenant+TenantInfo, mirroring the verifiedmigrating-from-django-tenants.mdguide), andTenantInfo(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 realdjust.tenantsAPI. - Demo/example apps no longer ship dead
@click/@input/@change/@submithandler bindings; migrated todj-*(#1683). The shipped client (09-event-binding.js) bindsdj-*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.pyviews/demo-classes plus the 3 demo.mdtranslation guides), top-levelexamples/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"withx-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) andmanage.py djust_check(no T001/@clickfindings for migrated views). - Runtime
SafeStringfrom 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 parallelget_valuepipe helper (used by the{% firstof a|md %}/{% cycle a|md ... %}emit path) applied filters via the plainapply_filter_full, dropping the runtime-safe flag. A custom filter thatmark_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<em>Hi</em>. This was fail-safe over-escaping, NOT an XSS — a parity gap, not a security hole. Fix: a newget_value_safereturns(Value, bool runtime_safe), threading the safe flag out of the pipe loop viaapply_filter_full_safe(mirroring the per-iterationruntime_safe = produced_safepattern from the #1660 Variable arm); theFirstOf/Cycleemit arms skip auto-escaping when the value is a genuine runtimeSafeString.get_valueis 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 realstr-subclass with__html__), so it can never under-escape a plain value. Pinned by 7 regression cases inTestFirstofCycleRuntimeSafe_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 RUNTIMEmark_safe()-ness through the{% firstof %}/{% cycle %}emit path viaget_value_safe, but that helper did not consult the name-basedsafe_output_filterswhitelist (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 %}(whereurlizeemits 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 customis_safe=Truefilter), mirroring the Variable/InlineIf arms exactly. The whitelist was hoisted from two inline copies into a single shared module constSAFE_OUTPUT_FILTERSso 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 inrenderer::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 intests/unit/test_rust_firstof_cycle_named_safe_1692.py(gate-off verified).client.min.jsno longer logsUncaught ReferenceError: applyPatches is not definedin production (#1688). A recurrence of the #1676 terser-mangle × IIFE class, different manifestation.45-child-view.jsreferenced the bareapplyPatchessymbol at two sites (_applyScopedPatchesand thedjust._applyPatchesexpose block), butapplyPatchesis declared inside12-vdom-patch.js's own inner IIFE and published only asglobalThis.djust.applyPatches. The bare cross-IIFE reference is out of scope: it silently no-ops in the unminified bundle (leavingdjust._applyPatchesunwired, soemitChildMountedEvents— the child-mounted lifecycle for embedded/sticky views — never runs) and throws in the terser-minified production bundle (served whenDEBUG=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 aliasglobalThis.djust.applyPatchesat both sites, which is minification-independent and also restores the intended_applyPatcheswiring. Pinned by a behavioral regression (tests/js/min_bundle_applypatches_1688.test.js) assertingdjust._applyPatchesis wired after load (gate-off verified:undefinedon the pre-fix bundle).dj-dialog-close-event(35-dj-dialog.js) and keyboard-navdj-clickdispatch (51-keyboard-nav.js) no longer reference a bare out-of-scopehandleEvent(#1706). Found by the new cross-IIFE static guard (above): both modules referenced the barehandleEventsymbol, which is declared in11-event-handler.jsinside the double-load-guardelse {}block (block-scoped) and published only asglobalThis.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 — thetypeof handleEvent === 'function'guard returned"undefined", so the dialogcloseevent and the keyboard-navdj-clickactivation silently no-opped — and would throwReferenceErrorunder terser-minified bundles. Exactly the #1688 class, two more sites. Fix: read the published aliasglobalThis.djust.handleEventat all four sites (minification-independent). Thedj_dialog/keyboard_navtest 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 ↔ manifestimport SCC indjust.theming(#1662, follow-up to #1661). After #1661 extractedget_theme_configto the leaf_config, the AST import graph (lazy + eager) still had a pre-existing, never-CodeQL-flagged strongly-connected component:theme_packs/manifestimportedregistry.get_registrywhileregistryimportedtheme_packs/manifestfor discovery. Fix (same leaf-module pattern as #1661): extractThemeRegistry+get_registryinto a new leaf module_registry_accessor.py(imports only stdlib) sotheme_packs/manifest/presets/ etc. reach the singleton WITHOUT importing back intoregistry; discovery (the onlyregistry → theme_packs/manifestedges) stays inregistry.pyand is installed as a hook viaset_discovery_hook, making the dependency one-directional.registry.pyre-exportsThemeRegistry/get_registry/register_*sofrom djust.theming.registry import …keeps working — no runtime, behavior, or public-API change.test_theming_no_cyclic_import.pyis tightened to assert the WHOLEdjust.themingpackage import graph is acyclic (Tarjan over all modules, counting bothfrom .X importandfrom . import Xedges) plus a leaf-purity gate — previously onlypresets/manager/css_generatorwere 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 rc1–rc18 entries below for the full
changelog of the stabilization cycle.
[1.0.0rc18] - 2026-06-01
Fixed
{% kanban_board %}card moves no longer stormhtml_recoveryon 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_recoveryegress) removed all comments viare.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'slast_vdomkept them. Fixed with a negative-lookahead regex (<!--(?!\s*/?dj-if\b).*?-->) that strips ordinary comments but preserves dj-if markers. Pinned bypython/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 emitsMoveSubtree(outer boundary) +InsertSubtree(inner boundary) with final-structure indices. The client appliedInsertSubtree(phase −1) beforeMoveSubtree(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 countSetTextlanded on a dj-if comment marker →html_recoveryon 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 putsInsertSubtree+MoveSubtreein 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 actualclient.jsin 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.jspins the ordering.
- Layer 1 — markers stripped from client HTML.
[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 (thedjust newscaffold'sreact/vote/post_messagedemos), 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 → arequest_htmlfull-HTML recovery on every gap (a re-fetch + morph storm), with an occasionallocation.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_viewnow tags broadcasts with the originating channel (aContextVarset during event handling) andserver_pushskips a broadcast whosesender_channelmatches the receiving session — leaving the delicate client version-check untouched. Broadcasts to other sessions and external pushes (Celery, cross-view;sender_channelisNone) are unaffected. Semantic note: a handler'spush_to_viewto its own view no longer echoes back to the triggering session — set state directly onselffor local updates (the normal pattern). Pinned bytests/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 nodj-id/dj-keyanchors. Since it wires drag-and-drop to amove_event, the natural usage is a statefulLiveViewthat 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 fullhtml_recoverymorph + extra round-trip. Cards now carrydj-key="<card id>"and columnsdj-key="<col id>"(the keyed-reconciliation anchor →VNode.key), so the differ reconciles by identity across the move instead of by position. Pinned bypython/djust/components/tests/test_kanban_djkey_1678.py. -
client.min.jsno longer crashes withReferenceError: ie is not definedin production (#1676). P0: after the #1635 single-IIFE wrap, terser--manglerenamed a cross-module function declaration (applyPatches→ie) and relocated it so a caller'sawait ie(...)no longer resolved — crashing the minified client (served whenDEBUG=False) and triggering a WebSocket reconnect loop. The unminifiedclient.jsresolves 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.shnow 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 justapplyPatches). 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-fnames→applyPatchesmangles toie→ 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
-
MoveSubtreeVDOM patch — matched{% if %}boundaries can now be repositioned (#1666). When adj-ifconditional 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#commentnodes that a plainMoveChildcan'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 emitsMoveSubtree { 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 inwire_protocol_snapshot.rs; behavior pinned bymatched_djif_boundary_repositioned_via_move_subtree(Rust) andtests/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 fromLIVEVIEW_ALLOWED_MODULEShas its WebSocket mount rejected and silently degrades to full-page HTTP re-renders — butmount()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 standaloneassert_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_dno longer mis-inserts under sibling reorder. The differ populatedref_dwith 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) honorsref_d(it doesinsertBefore(node, querySelector(':scope > [dj-id=ref_d]'))and only falls back to the index whenref_dis absent), so under a reorder that guess is the wrong reference and the new node lands in the wrong position. The differ now emitsref_d: Noneand relies on the index:InsertChildis applied last in the client's phase order (afterRemoveSubtree/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#1408invariant is preserved (aref_d, when present, must resolve in the OLD tree). Regression-pinned bykeyed_insert_ref_d_is_safe_not_a_wrong_guessincrates/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 staticis_safe=Trueflag / the built-insafe_output_filterslist), never by the filter result's runtimeSafeString-ness — unlike Django'srender_value_in_context, which escapes iff the final value lacks__html__. So a filter like@register.filter def md(v): return mark_safe(...)(nois_safe=True) had its HTML escaped on both the HTTP-prerender and WS-mount paths.apply_custom_filternow reports whether the Python result carries__html__;apply_filter_full_safethreads 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 genuinestrsubclass with__html__(a realSafeString) — Django stringifies any non-strvalue before the__html__check, and djust'sValueextraction stringifies arbitrary objects via__str__, so trusting__html__alone would let a non-strobject's attacker-controlled__str__reach output unescaped (XSS). Workaround no longer needed:@register.filter(is_safe=True)and pre-render+|safestill work. Pinned by the runtime-SafeString matrix (text/attribute context,x|md|upperre-taint,x|upper|md, plain-filter-still-escaped) and the non-str-__html__impostor XSS guard intests/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_valuepipe 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_MODULEShas 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) thedjust.V005system 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 atmanage.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 flaggedmyapp.views(parallel-path-drift, CLAUDE.md #1646). (2) The client's HTTP-fallback notice (11-event-handler.js), previously adjustDebug-gatedconsole.log(silent by default), is now a once-per-session, un-gatedconsole.warnpointing the developer atLIVEVIEW_ALLOWED_MODULES. Pinned bypython/djust/tests/test_v005_routed_allowlist_1674.py,test_assert_allowlisted_1674.py, andtests/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/Npatch failure. #1636'sInsertChild1/N patches failed(a false→true{% if %}add) was a manifestation of #1640 (the index resolvergetSignificantChildrenand the path walkergetNodeByPathdisagreeing on whether a regular HTML comment counts) and was confirmed fixed on rc14 by the consumer. #1640 shipped a predicate-level test (in thesignificant_children_comment_filter_1640suite); this adds the missing applied-patch-level pin for the chronically-reopened if-block cluster (#1358/#1408/#1550/#1552/#1555/#1636): anInsertChildwhose index points past a regular<!-- comment -->lands between the right significant siblings, not one slot early (the1/N). Gate-off proof (Action #1200/#1468): flippingisSignificantChildback 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 intests/js/insert_child_regular_comment_1636.test.js. No source change — guards an already-shipped fix.
[1.0.0rc15] - 2026-05-28
Fixed
@permission_requiredevent handlers no longer raiseSynchronousOnlyOperation(#1648). Sibling of #1638: the per-event handler-permission check (_validate_event_security) calledcheck_handler_permissionsynchronously from anasync def. For a handler decorated with@permission_required, that callsuser.has_perms(), which under Django's defaultModelBackendqueries the DB for a non-superuser — raisingSynchronousOnlyOperationin the event loop (and, unlike the object-permission path, with no fail-closed catch, so it propagated). The call is now wrapped insync_to_async, mirroring #1638 and the mount path.live_redirectto a different view now mounts the correct target (#1647). The client'sresolveViewPath()falls back to the current container'sdj-view— the source view — whenwindow.djust._routeMapis empty, which is the default for apps using plain Djangopath()URLconfs (nolive_session()). The server trusted that client-supplied class in thelive_redirect_mountframe, instantiated the source view against the destination URL's request, and raised "Failed to load view. Please refresh the page."handle_live_redirect_mountnow resolves the destination view server-side from the URL via Django's URL dispatcher and overrides the client-suppliedviewwhen the URL maps to a djustLiveView(falling back to the client value otherwise, solive_session()route maps are unaffected).live_session()is no longer a hidden prerequisite forlive_redirectacross plainpath()URLconfs.
[1.0.0rc14] - 2026-05-28
Added
LiveViewTestClient.assert_http_ws_djid_parity()— test-infra harness for HTTP-GET vs WebSocket-mountdj-idparity (#1642). Builds two independent instances of a view (one exercising the HTTP initial-page path viarender_full_templatethenrender_with_diff; one the WebSocket-mount baseline viarender_with_diffonly) and asserts they assign an identicaldj-idbaseline in thedj-rootsubtree. A divergence is thegetNodeByPath → nullfirst-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. Seedocs/website/guides/testing.md.
Fixed
-
djust new --with-dbprojects now create their tables on deploy (#1637). The scaffold ranmigrate --run-syncdbbut nevermakemigrations, so a--with-dbapp shipped with no migrations.--run-syncdbcreated the tables on the developer's machine (masking the gap), but a deploy runsmigratewithout--run-syncdb, so the app's tables were never created and the first request 500'd (no such table: <app>_<model>).djust newnow runsmakemigrationsbeforemigrate(generating the app'smigrations/__init__.py+0001_initial.py, which ship in the deploy artifact), and explicitly writes<app>/migrations/__init__.pyfor model-bearing apps so the migrations directory is a real package — not a namespace package the migration loader silently skips — even when--no-installis used. -
client.jsno longer throws a re-declarationSyntaxErroronlive_redirect/ bfcache restore (#1635).live_redirectkeeps the document alive and morphdom can re-attach the<script src="client.js">tag; a bfcachepageshowre-init follows the same path. Two classic<script>executions share one global lexical environment, so top-levelconst/let/classin the bundle re-declared at parse time on the second execution —Identifier '_TEXT_INPUT_TYPES' has already been declared— before the runtimewindow._djustClientLoadedguard 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 explicitwindow.*/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
getSignificantChildrencounted ALL comment nodes, whilegetNodeByPath(path traversal) and the Rust VDOM parser count onlydj-if-family boundary markers and drop every other comment. Index-based patches (InsertChild/RemoveChild/MoveChild) resolve their index viagetSignificantChildren, so a regular<!-- comment -->among siblings shifted the client index one slot off the server's — inserting/removing/moving the wrong node.getSignificantChildrennow uses the samedj-if-only predicate asgetNodeByPath.dj-ifmarkers 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) calledcheck_object_permissionsynchronously from anasync def, while the mount path wraps it insync_to_async. The developer's syncget_object()— the canonical ADR-017 pattern doingModel.objects.get(...)— then raisedSynchronousOnlyOperationin the event loop, and the fail-closedexcepttranslated it into a spurious "Access denied"permission_deniedframe on the first event of every URL-bound LiveView with a syncget_object(). The per-event call is now wrapped insync_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 callslive_redirect()),handle_eventtakes its auto-skip-render branch and returns via_send_noop. That branch — and the batched_dispatch_single_eventskip branch, plus theserver_pushanddb_notifybroadcast paths — flushed onlypush_events/flash/page_metadata/pending_layout/deferred, silently dropping queuednavigation/accessibility/i18ncommands. 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_updateare unaffected). Net reduction of ~29 lines inwebsocket.py; also closes the same latent gap on theserver_pushanddb_notifybroadcast paths. -
start_asynccompletion now refreshes the VDOM recovery baseline (#1636). When a client VDOM patch fails (e.g. an{% if %}block that adds a sibling), the client requestshtml_recoveryand the server serves — then clears —_recovery_html(one-time use).LiveViewConsumer._run_async_workre-renders and sends patches when astart_asyncbackground callback completes, but — unlikehandle_eventandserver_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_worknow sets the recovery baseline afterrender_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_buttonaccepts aconfirm=""kwarg (#1621). When non-empty, emits the standarddj-confirm="<message>"attribute consumed by djust's client.js (python/djust/static/djust/src/09-event-binding.js:7) — clicking the button shows a JSconfirm()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 skipdj_button(losing the theme integration / variant→class mapping from #1619) or wrap the tag in ad-hoc JS. The underlyingdj-confirmprimitive 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 ofevent=value — the attribute is useful on event-less buttons users have wired up via other directives.conditional_escapeneutralizes XSS surface, matching the existingeventattr escaping. Preset override supported via the existing preset-priority pattern (preset values fill defaults; explicit kwargs win). Discovered buildingdjust-org/djust-start's reset-demo button on djust 1.0.0rc12. 5 new regression cases inpython/djust/components/tests/test_dj_button_confirm_1621.py; gate-the-fix-off self-test (Action #1200/#1468) passes. -
PresenceMixin.online_countinstance attribute — zero-config{{ online_count }}template binding (#1611).PresenceMixinnow auto-maintainsself.online_count(an integer count of presences in the group) insidetrack_presence,untrack_presence,_restore_presence, and the new_on_presence_changebroadcast handler. Templates can use{{ online_count }}with zero scaffolding — noget_context_dataoverride required. The attribute is set on the view instance (NOT viaget_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 buildingdjust-org/djust-starton djust 1.0.0rc7. Behavior change: NEW public attribute onPresenceMixinusers. Existing code that already doesself.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 = Falseopt-in flag for anonymous-tab uniqueness (#1613). By default, two browser tabs of the same anonymous user share a Django session and therefore oneanon_<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. Whenpresence_unique_per_connection = True, anonymous users getanon_conn_<ws_session_id>derived from the per-WebSocket-connection ephemeral UUID instead, so each tab counts as a distinct presence. Authenticated users always userequest.user.idregardless 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_changedefault@event_handlerfor auto-broadcast fanout (#1614).track_presenceanduntrack_presencenow fire apush_to_viewbroadcast to a well-known handler name_on_presence_changeon the view class.PresenceMixinships a default@event_handler-decorated_on_presence_changethat refreshesself.online_counton the receiving session. Body is exclusivelyself._refresh_online_count()— explicitly does NOT calltrack_presence, so the broadcast loop terminates after one hop. Subclasses may override but should callsuper()._on_presence_change(**kwargs)to preserve count refresh. Behavior change: everytrack_presence/untrack_presencecall 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_changelistener.
Fixed
-
C003 now accepts uvicorn or hypercorn as an ASGI server (#1630). The check previously fired an INFO whenever
daphnewas missing fromINSTALLED_APPS, telling users topip install daphne. But djust's canonical recommendation has beenuvicornsince the README rewrite (uvicorn myproject.asgi:application) anddjust-org/djust-startships uvicorn by default — every uvicorn-based project was forced to ship a permanent"C003"inDJUST_CONFIG['suppress_checks']to silence guidance that pointed at the non-recommended server. New_has_asgi_server()helper probesdaphne/uvicorn/hypercornviaimportlib.util.find_spec(no actual import, can't ImportError-breakmanage.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 inINSTALLED_APPSbut afterstaticfiles) is unchanged. New cases inTestC003AsgiServers1630cover uvicorn-installed/hypercorn-installed/daphne-via-find_spec/no-server-at-all + helper unit tests. Updatedtest_no_suppress_by_defaultto stub_has_asgi_serverper Action #1200 so the suppression contract is isolated from the broadening. Impact ondjust-start:suppress_checksdrops from["C003", "T002"]to["T002"]. -
Broke the four-module
presets/registry/manager/css_generatorcyclic-import SCC indjust.theming(CodeQL alerts #2352/#2351/#1900/#1883). Extracted the built-in theme imports + theTHEME_PRESETSregistry dict into a newpython/djust/theming/_builtin_presets.pyleaf module.registry._do_discovernow importsTHEME_PRESETSfrom_builtin_presets(notpresets), andmanager.py/css_generator.pydefer theirget_preset/get_theme_configimports to call sites and pullThemePreset/ThemeTokensannotations from the leaf_typesmodule._builtin_presetshas no runtime dependency onpresets/registry/manager/css_generator, so the back-edge that closed every cycle in the SCC is gone. Back-compat preserved:presets.pyre-exports the named*_THEMEconstants viafrom ._builtin_presets import *so external code doingfrom djust.theming.presets import BLUE_THEMEkeeps working, and thedjust.theming.__init__public surface is unchanged. 7 new AST-based regression cases inpython/djust/tests/test_theming_no_cyclic_import.pypin 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'smount(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 calledtrack_presence()inmount()registered the connection twice; the HTTP-mount entry was an orphan that lingered forPRESENCE_TIMEOUT(~60s) until stale cleanup. After this PR,track_presenceearly-returns when_websocket_session_idis absent (set only byLiveViewConsumeron the WS path), so presence registers only once. Reporter's existingif hasattr(self, "_websocket_session_id"):workaround is now built-in. Behavior change: anyone callingtrack_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 = Falseclass-attribute marker — opt out abstract base classes from per-class V/Q system checks (#1605). A common pattern is to define an abstractBaseLiveView(LiveView)that subclasses extend for shared mount/auth boilerplate. The base typically has notemplate_nameand is never mounted directly, but V001 (missing template_name) and V005 (not inLIVEVIEW_ALLOWED_MODULES) still fired on it because the per-class check loop inpython/djust/checks.py:check_liveviewshad no abstract opt-out. The newabstract = Trueclass attribute mirrors Django'sMeta.abstractsemantics: 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 viacls.__dict__.get("abstract")so it is NOT inherited — subclasses of an abstract base are still validated as concrete unless they redeclareabstract = Truethemselves. Both the abstract opt-out and the globalsuppress_checksmechanism (see ### Fixed below) work; choose abstract when the intent is "this specific class is boilerplate" andsuppress_checkswhen the intent is "this check is the wrong shape for our codebase." Documented indocs/system-checks.md(new "Abstract base LiveView classes" section) anddocs/guides/error-codes.md(V001 + V005 entries). Behavior change: NEW public API —LiveViewgains anabstract: bool = Falseclass 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_namereturns the dotted name (inspect.getsource) for attribute-access calls — those didn't match the bare-nameSAFE_TYPESentries. Fix extendsSAFE_TYPESwith 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 likePath(p).read_text()anddatetime.now().isoformat()where_get_call_namereturns just the method name. Bareexists/is_file/is_dirare intentionally NOT added (too ambiguous with user code —some_record.exists()etc.); use the qualifiedpathlib.Path.exists(p)form,os.path.exists(str(p)), or# noqa: V008. Reporter's alternative (annotation-based trust —-> strreturn annotation) is deferred — would require resolving imports + inspecting target module annotations at static-check time. Discovered buildingdjust-org/djust-starton djust 1.0.0rc12. After this lands, the starter has zero local accommodations for framework quirks. 10 new regression cases inpython/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 viainnerHTML/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 bywindow.__djcHljsObserverInstalled) that watchesdocument.bodyfor added<pre><code class="language-*">elements and highlights any unmarked ones viahljs.highlightElement. The observer is installed on each of the three hljs-ready paths in the existing bootstrap (already-loaded, first-loads.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 viatypeof MutationObserver === 'undefined'so very old browsers gracefully fall through.highlight=Falsepath unchanged. 6 new regression cases inpython/djust/components/tests/test_code_block_observer_1625.py(source-text gates pin the install + scope + selector + idempotency flag). Discovered buildingdjust-org/djust-starton djust 1.0.0rc12 — companion to #1624. -
{% theme_head %}now auto-loads djust-components'scomponents.csswhendjust.componentsis inINSTALLED_APPS(#1624). Previouslytheme_head(from djust-theming) loaded only djust-theming's owncomponents.css. djust-components ships a separatecomponents.cssatpython/djust/components/static/djust_components/components.csswith layout rules for{% code_block %},{% card %},{% dj_button %}spinners, etc. — buttheme_headdidn't link it, so components rendered in user templates fell back to default flow layout (looked broken). Fix detectsdjust.componentsviadjango.apps.apps.is_installed("djust.components")inbuild_theme_head_contextand adds a conditional<link>next to the existing djust-theming link intheme_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. Withoutdjust.componentsinstalled, theme_head emits no extra link (graceful degradation, zero behavior change for users not on djust-components). 6 new regression cases inpython/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 buildingdjust-org/djust-starton 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 aSAFE_TYPESset 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 thoughmax(int, int)returns an int. Fix extendsSAFE_TYPESwith 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 aslist()), andfrozenset/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 vialist()first.complexandslicealso remain flagged. The V006 (Warning) path is untouched. Discovered buildingdjust-org/djust-starton djust 1.0.0rc7. 9 new regression cases inpython/tests/test_checks_v008_builtins_1609.py; 15 existing V008 tests atpython/tests/test_checks.py::TestV008NonPrimitiveInMountcontinue to pass unchanged. Gate-the-fix-off self-test (Action #1200/#1468) passes. -
dj_button(variant="danger")now renders styled (#1619).dj_buttonpreviously producedclass="btn btn-danger"unconditionally from thevariantkeyword, but djust-theming'scomponents.css(loaded bytheme_head) only ships rules for.btn-primary,.btn-secondary,.btn-destructive,.btn-ghost, and.btn-link— sovariant="danger",variant="success", andvariant="warning"rendered with class names that had no matching CSS rule. (scaffold.cssDOES have.btn-danger/.btn-successrules but is not loaded bytheme_head.) Fix introduces a_DJ_BUTTON_VARIANT_CLASS_MAPinpython/djust/components/templatetags/djust_components.pymapping keyword variants to the canonical CSS class names;dangeris now an alias fordestructive(matching shadcn/Tailwind convention). Variants not in the map (including the now-deprecatedsuccess/warningkeywords, plus user-defined custom variants) pass through asbtn-<variant>viaconditional_escape, preserving the existing security boundary and enabling user theme classes. Thedangerkeyword 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 buildingdjust-org/djust-starton djust 1.0.0rc12. 5 new regression cases inpython/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, theSetTextpatch landed on the wrong node — chip stayed at"1 online"forever while the unrelated reaction count visually became"2"(state still said1). 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-leveltext_region_fast_path, which is already sound for this scenario. Bug class: any{{ var }}<literal>,<literal>{{ var }}, or{{ a }}{{ b }}template pattern. The reporter'sstate= works / handler= brokenframing was refuted by code inspection: bothpush_to_viewpaths converge at_sync_state_to_rust→set_changed_keys→render_with_diffand take the sametext_fast_path; the fix addresses the root cause. Discovered buildingdjust-org/djust-starton djust 1.0.0rc12. 3 new regression cases inpython/djust/tests/test_text_fast_path_misroute_1617.py(bug repro, adjacent{{a}}{{b}}, pure-case regression backstop); 6 existing#1529content-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=truein the WS mount message, the server's WS-mount HTML was previously used ONLY to stampdj-idattributes onto the existing pre-render DOM (_stampDjIds(data.html)atpython/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 callsmorphChildren(the same helper used byhandleEmbeddedUpdateat03-websocket.js:1127and thehtml_recoverypath at03-websocket.js:641) to diff the pre-render DOM against the WS-mount HTML and apply the differences.morphChildrenpreserves keyed nodes by id, so the dj-id stamp step is folded into the morph. PR #1615'strack_presence/untrack_presenceauto-broadcast partially masked this bug for the specificonline_countcase (the broadcast synthesized a patch frame post-mount); this fix closes the general bug class for non-presence WS-context state. Discovered buildingdjust-org/djust-starton djust 1.0.0rc7. 8 new JS regression cases intests/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 inpython/djust/tests/test_ws_mount_prerender_divergence_1610.py. Gate-the-fix-off self-test (Action #1200/#1468) passes: reverting themorphChildrencall 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 inpython/djust/checks.py::check_liveviews. V002 (nomount()method), V003 (wrongmount()signature), V004 (handler-like name without@event_handler), V007 (event handler missing**kwargs), and Q007 (overlappingstatic_assigns∩temporary_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 globalDJUST_CONFIG['suppress_checks']shortcut; the per-classabstract = Trueopt-out from #1605 already covered abstract classes but the global-by-ID escape hatch was missing. 10 regression cases inpython/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 hatchDJUST_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 thedjust-org/djust-startstarter template, which ships aBaseLiveViewpattern that hits both #1604 and #1605. Reporter'sSILENCED_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'], andSILENCED_SYSTEM_CHECKS). 9 regression cases inpython/tests/test_checks_1604_1605.pylock 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 makestest_v001_suppressed_via_djust_configfail, restoring it makes it pass.
[1.0.0rc11] - 2026-05-24
Fixed
- Cross-origin
dj-navigate/live_redirect/live_patchno longer throwsSecurityError(#1599). Reported from production djust.org: clicking<a dj-navigate="https://djustlive.com/">(or any cross-origin URL) crashed the JS runtime withUncaught 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()andhandleLivePatch()inpython/djust/static/djust/src/18-navigation.jsboth buildnewUrl = new URL(data.path, window.location.origin)— whendata.pathis an absolute URL, theURL()base argument is ignored, sonewUrlbecomes the cross-origin URL. The subsequentpushStatethen triggers the browser's same-origin policy (history API forbids cross-origin pushState). Fix: detectnewUrl.origin !== window.location.originBEFORE the pushState call in both handlers; on cross-origin, fall back towindow.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()(thedj-patchclick handler) was incidentally safe because it buildsnewUrlfromwindow.location.hrefand 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 intests/js/navigation.test.jsunder theissue #1599 — cross-origin pushState guarddescribe block:handleLiveRedirect with cross-origin path does NOT call pushState,handleLivePatch with cross-origin path does NOT call pushState, andsame-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 futureregister_X()API indjust.theming.registrywhose matching module-levelget_X()ignores the registry. New filepython/djust/tests/test_theming_register_get_roundtrip_invariant.pycarries an authoritativeROUNDTRIP_PAIRStable 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 secondtest_no_unaudited_register_apis_1597AUDIT test grep-walksdjust.theming.registry's public surface and fails loud if anyregister_*function exists without a row inROUNDTRIP_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()atpython/djust/theming/theme_packs.py:1216). Gate-the-fix-off self-test (Action #1200/#1468) passes: revertingget_presetto its pre-#1596 broken shape, exactly 1 of the 4 new test cases fails (theregister_preset→get_presetcase) — 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 runtimeRegistry._presetsdict that the theme manager, theme switcher, and introspection APIs all consult; butpresets.get_preset()— the function the CSS generator path ultimately calls to render--primaryetc. into:root— read only from the staticTHEME_PRESETSmodule dict, blind to runtime registration. Result: any consumer following the documentedregister_preset()API inAppConfig.ready()got their custom palette silently replaced with the default slate-blackTHEME_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 intheme_packs.get_theme_pack()(python/djust/theming/theme_packs.py:1216-1222):get_preset()now consultsget_registry().get_preset(name)first, then falls back toTHEME_PRESETS.get(name, DEFAULT_THEME). Same shape, sameimport .registryinside the function to avoid the circular import that bites if registry imports back from presets. Covered by 3 new regression tests inpython/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_THEMEfallback 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 publicregister_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 / commita5e2c50c(feat(websocket): persist LiveView state on WS event for reconnect continuity) as the single feature commit in that window. PR #1466 changedhandle_mount's LOAD gate fromif has_prerendered:toif has_prerendered or saved_state:AND made therequest.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 viaenable_state_snapshot. For non-opt-in views, that restoration ran AFTERmount()had initialized the view; the nextrender_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'squerySelector(':scope > [dj-id=X]')failed, fell back to index resolution, and removed the wrong node afterInsertSubtreehad shifted positions — the old wizard-step subtree survived in the DOM. Visible symptom: the #1552 wizard step-leak. PR #1478 (commit066d7f05, closing issue #1475) later added a SAVE-side gate onenable_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 thesaved_stateread inhandle_mountsymmetrically 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 oneh2.card-titlein 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; thetest_load_gate_loosened_fires_on_saved_state_without_has_prerenderedassertion was strengthened to require BOTHrequest.sessionANDenable_state_snapshotin 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 inpython/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) capturingstate_before+state_after+vdom_patchesfrom 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: aBugCapturedataclass holding the 3 minimal fields needed to reproduce a broken transition (state_before,state_after,vdom_patches), anencode()/decode()URL-fragment round-trip using a versioneddjbug1.<base64-urlsafe>wire format, ascrubhook with a ready-madescrub_fields(*names)helper for PII redaction, a wire-visiblescrubbed_fieldslist (names only, never values) so reviewers know what was held back, and anencode_view_state(view, scrub=...)convenience that pulls the latest event snapshot + VDOM patches from a view withtime_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()raisesRuntimeErrorin production (DEBUG=False) unless the deployer explicitly opts in viaDJUST_BUG_CAPTURE_PROD_OPT_IN = True(literalTrueonly, 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 withvalidate=Trueagainst 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 replayCLI + framework-levelLiveView.time_travel_excluded_fieldsclass attribute withdjust checkV012 enforcement) are tracked as separate v1.1.0 issues #1561 and #1562. Newpython/djust/bug_capture.pymodule; new docs pagedocs/website/guides/bug-capture.mdlinked from_config.yamlandindex.md. Framework integration trade-off:encode_view_state()takespatchesas an explicit required parameter (the caller obtains them fromview.render_with_diff()and passes in). The original sketch readview._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 callsrender_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 likestate["user"]["password"]need a custom callable; iter C (#1562) will add framework-leveltime_travel_excluded_fieldsdeclarative scrub. Covered by 36 regression cases inpython/djust/tests/test_bug_capture.pyacross 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_jsonandtest_raises_on_patches_wrong_typepin the new_coerce_patchesboundary), 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 viaSET search_pathon every request, which is a documented production footgun under ASGI + LiveView — every WebSocket event (tick_interval,push_to_view, presence,@notify_on_save) re-entersTenantMainMiddlewareand issues a Postgres roundtrip, exhausting the connection pool under sustained load (#1556 was the prod 503 incident that motivated this deprecation). Thedjust.tenantsrow-level path does not have this failure mode by construction (noSET search_pathin 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. Thedjust.C014system check (also in this release; see### Addedbelow) is the in-product breadcrumb pointing existing django-tenants users at the deprecation + migration. Reflected indocs/website/guides/multi-tenant.md(the "Choosing Your Multi-Tenancy Strategy" section explicitly marks django-tenants as deprecated under djust and framesTENANT_LIMIT_SET_CALLS = Trueas a stopgap, not a fix). Behavior change: framework-level — none. Documentation/messaging change: substantial.
Changed
djust.C014hint anddocs/website/guides/multi-tenant.mdupgraded 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### Deprecatedabove), the messaging now leads with migration as the recommended path and treatsTENANT_LIMIT_SET_CALLS = Trueas a stopgap rather than a long-term fix. Specific changes: C014's primary warning message now explicitly flags django-tenants as deprecated (visible inmanage.py checkoutput without expanding hints); C014'shintleads with migration todjust.tenants+ link to the strategy guide, then describes theTENANT_LIMIT_SET_CALLS = Truestopgap; C014'sfix_hintreorders 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 inpython/djust/tests/test_c014_multi_tenant_asgi.py::TestC014HintQuality(16 total, up from 11): hint mentionsdjust.tenants, hint links the strategy guide, hint marks django-tenants deprecated,fix_hintleads 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 stopgapTENANT_LIMIT_SET_CALLS = Trueis missing (#1556). Surfaces both the deprecation (see### Deprecatedabove) and the misconfiguration that caused a production 503 on djustlive: under ASGI + django-tenants, every WebSocket event re-entersTenantMainMiddleware→set_tenant()→SET search_path. LiveView amplifies this —tick_intervalpolling,push_to_viewre-mounts, presence updates, and@notify_on_savelistener re-mounts each re-enter the middleware. WithoutTENANT_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_tenantsis inINSTALLED_APPSORTENANT_MODELis set, (2)ASGI_APPLICATIONis set, (3)TENANT_LIMIT_SET_CALLSis unset orFalse. Emits aDjustWarningwhose primary message explicitly flags django-tenants as deprecated; thehintleads with the migration recommendation (link todocs/website/guides/multi-tenant.mdand tracking issue #1559) and describesTENANT_LIMIT_SET_CALLS = Trueas the stopgap; thefix_hintfollows the same order. Suppressible viaDJUST_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-reviewlabel) for v1.1.0. New helper_check_multi_tenant_asgi_set_callsinpython/djust/checks.py. Covered by 16 regression cases inpython/djust/tests/test_c014_multi_tenant_asgi.pyacross 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 whenlast_vdommigrates across worker threads OR is msgpack-roundtripped throughInMemoryStateBackend.get(). The thread-local djust_id counter generates monotonically-increasing ids duringparse_html_continue, but the new thread's counter is independent oflast_vdom's ids. The next parse generates ids1..kthat collide with surviving ids inlast_vdom. TheInsertSubtree.htmlpatch 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 (commit0a119962, serde-default fix), msgpack deserialize failed silently andstate_backends/memory.py:118returnedNoneon 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: Nonepropagation post-#1538 — was empirically disconfirmed by VNode-level reproducers showing the differ correct at both 0.9.6rc2 AND 1.0.0rc4.) Fix: before eachparse_html_continueinRustLiveViewBackend::render_with_diffand the text-fast-path entry, walklast_vdom, computemax_djust_id_in(old_vdom), and callensure_id_counter_at_least(max + 1). The thread-local counter becomes effectively per-view, surviving thread handoff and msgpack roundtrip. Three new public helpers indjust_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 incrates/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 inpython/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 raisedTemplateSyntaxError. The mismatch was silent: templates rendered fine via the LiveView WebSocket path (Rust) and crashed viaclient.get()in pytest, Django's debug error renderer, or any view usingrender()directly. Follow-up to #1423. Fix: newdjust.template.loaders.FilesystemLoaderanddjust.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 inTEMPLATES['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 inpython/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
idna3.11 → 3.15 — patches CVE-2026-45409 (GHSA-65pc-fj4g-8rjx, Dependabot alert #101). Specially crafted inputs toidna.encode()("٠" * Nor"・" * N + "漢") hit thevalid_contextofunction 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.idnais a transitive runtime dep (pulled in byanyio/httpx/httpcore/requests); the bump is a lockfile-only change viauv lock --upgrade-package idna, no direct-dep change inpyproject.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.requestno longer triggers a "non-serializable ASGIRequest" warning on every mount/event (#1545).self.requestwas assigned by the HTTPpost()path (mixins/request.py:489) and the WebSocket path (websocket.py:1940) AFTER__init__, so it sat OUTSIDE_framework_attrsand the state-snapshot machinery treated theASGIRequestas user state — hitting the non-serializable fallback atserialization.py:557and logging "LiveView state contains non-serializable value: ASGIRequest …" on every mount AND every event for everyLiveView. The warning was cosmetic (the frameworkstr()-stringifies the value and re-setsself.requestto 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: assignself.request: Any = NoneinLiveView.__init__BEFORE the_framework_attrs = frozenset(self.__dict__.keys())line atlive_view.py:526—requestis now captured as framework state and excluded from the user-state snapshot. Matches the_framework_attrssnapshot-order invariant (#1393). The fix also adds"request"to the_FRAMEWORK_INTERNAL_ATTRShard-coded frozenset used by_debug_state_sizesand the debug-toolbar observability path (discovered during regression-suite verification — 2test_debug_state_sizes_*tests started reportingrequestas user state until both filters were updated). Covered by 5 regression cases inpython/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_liveis nowcargo test-able —extension-modulegated behind a default-on Cargo feature (#1543).crates/djust_livecarried PyO3'sextension-modulefeature unconditionally, socargo test -p djust_livefailed at link time withld: symbol(s) not found ... Py_True— the crate that holdsdjust._rust's entry point, the actor system, theRustLiveViewbackend, and (since #1541 / PR #1546) thePatchResponseround-trip regression tests had no fast Rust-native test feedback loop.make testworked 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"]), somaturin develop/cargo buildare unchanged butcargo test -p djust_live --no-default-featuresnow links against libpython and runs. 37 djust_live tests now execute (including the 4msgpack_round_trip_patch_response_*regression tests from PR #1546 / #1541 that previously compile-checked only). The Makefiletest-rusttarget, the paralleltesttarget, and the CI workflow (.github/workflows/test.yml) all gained a Phase 2 invocation that runs the djust_live tests with--no-default-featuresafter the existing workspace-minus-djust_live pass. Maturin build path verified end-to-end (wheel build → import).PatchResponsemsgpack round-trip is now positionally-stable for everyNone/Somecombination ofpatchesandhtml(#1541). Sibling audit of #1538.PatchResponseis a plain#[derive(Serialize, Deserialize)]struct incrates/djust_live/src/actors/messages.rs, so under msgpack it serializes as a positional array — and its first two fields,patches: Option<Vec<Patch>>andhtml: Option<String>, carried#[serde(skip_serializing_if = "Option::is_none")]without#[serde(default)]. The fix that worked for #1538 (VNode.djust_id— adddefault) does not generalize: that fix only works for STRICTLY TRAILING optionals. For leading optionals likePatchResponse's,skip_serializing_ifshifts later array elements into the wrong positional slot on deserialize — anddefaultcannot repair this because the deserializer isn't running out of elements; it's reading wrong-typed values at the wrong positions (empirically witnessed incrates/djust_vdom/tests/wire_protocol_snapshot.rs :: msgpack_skip_with_default_works_for_trailing_optional_only). The correct fix forPatchResponseis to removeskip_serializing_ifentirely —Noneis then serialized as msgpacknil(1 byte) and positional slots stay aligned. This is defense-in-depth:PatchResponseis not currentlyrmp_serde::to_vec'd on any production path (only the innerVec<Patch>is atlib.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 ofPatchResponsenow always includes thepatchesandhtmlkeys (nullrather than omitted); no current consumer parsesPatchResponseJSON, but the existing inlineserde_jsontest was updated to reflect the new always-present shape. Layer B regression tests forPatchResponseitself live inline inmessages.rsand currently compile-check only (cargo test -p djust_liveis blocked by #1543's unconditionalextension-modulefeature); they will execute automatically once #1543 lands. 3 newmsgpack_*cases incrates/djust_vdom/tests/wire_protocol_snapshot.rsand 4 newmsgpack_round_trip_patch_response_*cases incrates/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 everysync_to_async/async_to_synccall 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 zerosync_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_snapshotopt-in mismatches are now surfaced — enforcement side (#1471, ADR-018 iter 18c). Completes ADR-018 and closes #1471. Sticky-child persistence requires both the child class and its embedding parent to setenable_state_snapshot = True(ADR-018 Decision 5 — restore must be tree-consistent). A child that opts in under a parent that does not is a misconfiguration: the child looks like it should persist, but iter 18a's both-opt-in gate silently skips its save. This iteration adds two enforcement mechanisms. Static: a newdjust.V011system check (check_sticky_child_optin, category V, aDjustWarning) scans templates for{% live_render ... sticky=True %}tags, resolves the embedded child class viaimport_string, matches the embedding parentLiveViewbytemplate_name, and warns when the child opts in but a matched parent does not. It is conservative — dynamic{% live_render variable %}paths, unresolvable child classes,{% verbatim %}doc examples, and templates with no statically-resolvable parent are all skipped, so it produces no false positives — and is suppressible viaDJUST_CONFIG['suppress_checks']. Runtime: a one-shotlogger.warning(warn_sticky_child_optin_skip) fires the first time a child save is skipped for this reason, at most once per(parent class, sticky_id), wired into both the WebSocket save path (websocket.py) and the HTTP-POST save path (mixins/request.py). This iteration changes no save (18a) or load (18b) logic — it is enforcement plus the newdocs/website/guides/sticky-child-persistence.mdguide only. Covered by 18 regression cases acrosstests/unit/test_checks_v011_sticky_optin.pyandtests/unit/test_sticky_optin_runtime_warning.py(including the #1459 empirical canary and the #1468 gate-off self-test). - Sticky-child
LiveViewstate is now restored on reconnect — LOAD side (#1471, ADR-018 iter 18b). Completes the round trip started by iter 18a. When{% live_render sticky=True %}constructs a sticky child during a render, the tag now — before calling the child'smount()— checks the session for the state iter 18a saved (liveview_<parent_path>__sticky__<sticky_id>). If a saved entry exists and both the child and its parent opted intoenable_state_snapshot, the child's public state is restored viasafe_setattrand its private state via_restore_private_state, the_restore_*side-effect replay runs (upload configs / presence / listen channels), and the child'smount()state-init is skipped — mirroring the parentLiveView's own skip-mount()-on-saved-state path. Restore is tag-driven (per ADR-018 Decision 2) and covers the WebSocket, HTTP-POST, and HTTP-GET render paths through the single{% live_render %}hook. A corrupt or partial session entry falls through to a freshmount()rather than breaking the render. The opt-indjust checkwarning + guide docs ship as iter 18c. Covered by 8 regression cases inpython/djust/tests/test_sticky_child_restore_1471_18b.py. - Sticky-child
LiveViews now persist their state across a WebSocket reconnect — SAVE side (#1471, ADR-018 iter 18a). A fullLiveViewembedded with{% live_render sticky=True %}is a sticky child: it is registered on the parent'sStickyChildRegistryand its events are routed byview_id. Until now the per-event state-save block (websocket.py) was gatedtarget_view is self.view_instance, so sticky-child events were skipped entirely — a sticky child's event-driven state was silently lost on a reconnect (page refresh, network blip, snapshot/restore), and the HTTP path had the same gap. This iteration adds the SAVE side: when a sticky-child event fires and both the child and its parent haveenable_state_snapshot = True, the child's public + private state is now written to a stable session keyliveview_<parent_path>__sticky__<sticky_id>(keyed on the child's stablesticky_idclass attribute, never the volatile per-process_view_id). The same parent-driven sweep was added to the HTTP POST path so both transports persist consistently. A GC ledgerliveview_<parent_path>__sticky_idsrecords the sticky ids rendered each cycle and prunes session entries for children no longer rendered. The matching LOAD/restore side ships next as ADR-018 iter 18b; opt-in enforcement + adjust checkwarning ship as iter 18c. Onlysticky=Trueembeds (which have a stablesticky_id) are persistable; non-sticky embeds are unaffected. Covered by 7 regression cases inpython/djust/tests/test_sticky_child_persistence_1471.py. - Keyboard interaction for the djust-native component library — focus trap,
Esc-to-close, and arrow-key roving navigation, out of the box (#1522). Accessibility phase 2 ships the client-side keyboard operability layer that PR #1491's component ARIA pass deliberately deferred — the roles and states it emitted are now keyboard-driveable. A new client-JS module (python/djust/static/djust/src/51-keyboard-nav.js) adds W3C ARIA Authoring-Practices keyboard behavior to the four djust-native templatetag components (thedj-*class family): a modal/dialog traps focus (Tab from the last focusable descendant wraps to the first, Shift+Tab wraps the other way, a no-focusable-children dialog traps focus on the container, and nested dialogs maintain a stack so the trap andEscalways act on the top-most dialog), focus moves into a dialog when it opens and is restored to the previously-focused element when it closes, andEscdispatches the modal's configured close event so server state stays in sync; a tablist gets ArrowLeft/Right rovingtabindexplus Home/End (manual activation — arrows move focus, Enter/Space activates); an accordion gets ArrowUp/Down focus movement plus Home/End (headers keep their native tab order, notabindexjuggling); and a dropdown menu gets ArrowUp/Down roving plus Home/End andEsc-to-close (which returns focus to the trigger). It is CSP-strict (Action #183): one delegatedkeydownlistener ondocumentplus a single document-levelMutationObserverfor focus-in-on-open / focus-restore-on-close — no inline scripts, no template changes, and delegation survives morphdom re-renders for free. The Bootstrap-flavoured_simple.pycomponent classes (data-bs-togglemarkup) are intentionally out of scope — those are Bootstrap-JS driven. The module adds +1121 B gzipped toclient.js. Covered by 27 cases intests/js/keyboard_nav.test.js. djust_audit --a11y— a new accessibility-audit mode for thedjust_auditmanagement command (#1523).python manage.py djust_audit --a11yruns theYaccessibility system checks (Y001–Y004 — missing accessible names, imagealttext, form-control labels, and positivetabindex) as a standalone mode and reports the findings, mirroring the existing--ast/--livemode-branch architecture. It composes with--jsonfor a machine-readable{"a11y_findings": [...], "summary": {...}}envelope and with--strictfor CI exit-code semantics. Because everyYfinding is aDjustWarning(there is no error tier), the exit-code contract is precise: normal mode always exits 0 (a stray false positive never breaks a build), and--strictexits 1 if any finding exists. This brings accessibility into thedjust_auditworkflow alongside the existing security (--ast) and runtime (--live) audits. Covered by 7 cases inTestA11yMode(python/tests/test_audit_command.py).djust._rustis now declared free-threaded-safe — no-GIL CPython users keep the GIL disabled (#1432). Importing thedjust._rustextension into a free-threaded CPython interpreter (python3.13t/python3.14t) previously made CPython auto-re-enable the GIL for the whole process — emitting aRuntimeWarningand silently downgrading every no-GIL user back to the GIL'd path — because the extension had not declared free-threading support. The PyO3 module is now marked#[pymodule(gil_used = false)](PyO3 0.25), which writes thePy_mod_gil = Py_MOD_GIL_NOT_USEDslot CPython reads to skip the auto-re-enable. The declaration is backed by a full thread-safety audit of every_rust-reachable shared global,#[pyclass]type, cross-threadPy<T>/PyObject, the Tokio actor system, the template registries, and the recursive Python↔Rust converters — Rust'sSend/Syncauto-trait checking statically verifies everystaticis correctly synchronized, with no shared mutable state lacking a lock or atomic. GIL'd interpreters (3.12 and the standard 3.13/3.14 builds) are entirely unaffected. Guarded by 6std::threadconcurrency regression tests acrosscrates/djust_templates/tests/free_threaded_safety.rsandcrates/djust_vdom/tests/free_threaded_safety.rs, plus a Pythonthreadingcall-path smoke test. Out-of-scope free-threading hardening (optionalRwLock/frozentweaks, apython3.14tCI leg) is tracked in #1534.optimistic,cache,client_state, andbackgroundare now re-exported from the top-leveldjustpackage (#1489). These four decorators are stable public-API symbols but were previously reachable only viafrom djust.decorators import …— they were absent from the top-leveldjustpackage's__all__. They are now also importable directly asfrom djust import optimistic, cache, client_state, background, matching every other public decorator (event_handler,action,computed, …). The top-level names are the same objects as thedjust.decoratorsoriginals — a pure re-export, not a redefinition — and thefrom djust.decorators import …path continues to work unchanged. Purely additive and SemVer-safe; this resolves finding F3 of the v1.0.0 API-stability audit (docs/API_STABILITY.md§F3 updated accordingly). Covered by 4 cases inpython/djust/tests/test_top_level_reexports_1489.py.
Changed
- Free-threaded hardening — dead-code removal,
frozenpyclasses,RwLocktemplate registries, and apython3.14tCI leg (#1534). A bucket of post-#1432hardening, deliberately deferred from #1432's scope per the broader-sweep discipline. (1) The unused Rust-sideCOMPONENT_REGISTRYand its three accessors incrates/djust_componentswere confirmed dead (zero call sites, never exported throughdjust._rust) and removed. (2)SupervisorStatsPyandSessionActorHandlePyare now#[pyclass(frozen)]— both are immutable / all-&self, sofrozendrops PyO3's per-instance runtime borrow-check overhead. (3) The four Rust template registries (tag / block / assign / filter) moved fromMutextoRwLock, so concurrent renders on a free-threaded interpreter share the read lock instead of serializing on registry lookups — registration (one-time bootstrap) takes the write lock, dispatch takes the read lock. (4) A new non-blockingpython3.14tCI job runs the free-threadedthreadingsmoke test on a genuine free-threaded interpreter, where the GIL-re-enable assertion (previouslyskipif-guarded) becomes real. All four are internal hardening — no public API or behavior change for application code. Guarded by a newrwlock_registry_allows_simultaneous_readersconcurrency test incrates/djust_templates/tests/free_threaded_safety.rs.
Fixed
- VDOM incremental diff no longer mis-paths
SetTextpatches when 2+ dynamic{{ }}text values change in one update (#1529). The text-fast-path'sbuild_fragment_text_map(crates/djust_live/src/lib.rs) mapped each rendered template fragment to the first VDOM text node whose content string equalled the fragment. Content equality is not a unique key: two template variables that render the same baseline string — e.g.{{ a }}and{{ b }}both0at mount — both matched the first such node, collapsing both map entries onto one VDOM path.render_with_diff()then emitted everySetTextpatch at that single path, so a page reliably updated only its first dynamic{{ }}text value while later ones were mis-pathed onto it (and the in-memory VDOM node at that path was mutated twice while its sibling was never touched). The fix tracks aVec<bool>parallel to the collected text nodes and claims each node at most once — the first unclaimed matching node — making the fragment→node map a bijection over matched fragments. Both the fragment list and the text-node collection are in document order, so first-unclaimed-match is positionally stable. No change to the VDOM differ, parser, patch types, or the patch-emission loop. Covered by 6 regression cases intests/unit/test_vdom_settext_mispath_1529.py. ThemeMixinviews now emit thecomponents.csslink and valid anti-FOUC JS —theme_headwas rendered with an incomplete context (#1531).ThemeMixin._setup_theme_context()renderedtheme_head.htmlwith only 3 of the 8 context keys the template consumes, omittinginclude_component_link,cookie_prefix_js,direction,deferred_css_block, andcomponent_css_block. Two visible breakages followed: the{% if include_component_link %}guard was falsy so the<link>todjust_theming/css/components.csswas never emitted (theme components —theme_panel, etc. — rendered unstyled in anyThemeMixinview), andwindow.__djust_theme_cookie_prefix = {{ cookie_prefix_js }};rendered aswindow.__djust_theme_cookie_prefix = ;— a JavaScript syntax error that broke the whole anti-FOUC inline<script>. The{% theme_head %}simple tag built the full context correctly, so{{ theme_head }}via the context processor was unaffected — only theThemeMixinpath was broken. This is the #1452 context-drift bug repeated on a third consumer oftheme_head.html. The fix extracts a sharedbuild_theme_head_context()so thetheme_headtag andThemeMixin._setup_theme_context()build the head context from a single source of truth — the two paths can no longer drift. Behavior change:ThemeMixinviews now also receive the same critical-CSS / deferred-CSS split that{% theme_head %}produces whencritical_cssis enabled (previously the mixin built a single combinedcss_block) — a consistency improvement, no migration needed. Covered by 6 cases inTestThemeMixinThemeHead(python/djust/tests/test_theming_context_cache.py), including aThemeMixin-theme_head-≡-{% theme_head %}output-symmetry pin.- A dropdown nested inside a modal/dialog now receives arrow-key and
Esckeyboard routing (#1533). The keyboard-interaction module (51-keyboard-nav.js, shipped in #1522) routed every keydown inside an openrole="dialog"through the dialog branch and returned early — so adropdowncomponent rendered inside a modal got no arrow-key roving navigation, andEscalways closed the whole dialog instead of the open dropdown. The dialog branch now checksTabfirst (the focus trap is unchanged), then, when the event target is within a.dj-dropdowncontained by the dialog, delegates Arrow/Home/End to the dropdown handler and routesEscto close an open inner dropdown before falling back to closing the dialog. Plain dropdowns and plain dialogs are unaffected. Covered by 9 new cases in thekeyboard-nav — dropdown nested in dialogtest block (tests/js/keyboard_nav.test.js). VNodemsgpack round-trips no longer fail when a node has nodjust_id(#1538). The RustVNodestruct'sdjust_idfield carried#[serde(skip_serializing_if = "Option::is_none")]but no#[serde(default)]. Under msgpack a struct serializes as a positional array, so aNonedjust_iddropped the trailing element and produced a 5-element array — which the derived 6-element deserializer rejected withinvalid length 5, expected struct VNode with 6 elements. Because the HTML parser assignsdjust_id = Noneto every text node, any view whose VDOM tree contained text hit this:RustLiveView.deserialize_msgpackfailed insideInMemoryStateBackend.get/RedisStateBackend, the cached state entry was discarded, an error was logged on every WebSocket resume, and cross-reconnect state continuity was lost for the affected view. Adding#[serde(default)]lets the sequence deserializer fill a missing trailing element withNone. The change is deserialize-only — serialized bytes are byte-identical, and a new deserializer still reads old 6-element payloads — so there is no wire-format migration. The #1448 wire-protocol snapshot suite tested only the JSON (named-map) encoding, which is why it missed this; it now also hasrmp_serde(msgpack, positional) round-trip coverage. Covered by 3msgpack_round_trip_*cases incrates/djust_vdom/tests/wire_protocol_snapshot.rsand 2 inTestVNodeMsgpackRoundTrip(python/tests/test_serialization_hardening.py).
[1.0.0rc3] - 2026-05-18
Added
scripts/check-doc-snippets.pygained acheck_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 inREADME.md/QUICKSTART.mdis now also scanned for the security/style anti-patterns the djust PR-checklist auto-rejects: aprint()call, aprint(f"...")call, an interpolatingmark_safe(f"..."), a bareexcept: 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_exemptis 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 bytests/test_check_doc_snippets.py— 28 tests.scripts/AUDIT_TEMPLATE.md— fill-in-the-blank template for newscripts/check-*.pyaudits (#1515). Codifies the canonical audit-script shape — therun()/build_arg_parser()/main()skeleton, the exit-code convention, the four wiring points (.pre-commit-config.yaml,.github/workflows/test.yml, amaketarget, andscripts/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.mdnow references it. Internal contributor tooling.- ARIA for the P2/P3 component library — built-in roles, states, and accessible names for
progress,badge,tooltip, andavatar(#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.progressgetsrole="progressbar"plusaria-valuenow/aria-valuemin/aria-valuemax.badgegets a visually-hidden status-text element for screen readers, with its decorative dot markedaria-hidden="true".tooltipgetsrole="tooltip"on the tip element andaria-describedbywiring it to its trigger.avatarmarks its initials-fallback path withrole="img"+ anaria-label, and marks the decorative status spanaria-hidden="true". A decorative-iconaria-hidden="true"sweep was also applied across the P2/P3 component templates.cardwas deliberately left unchanged — it is a generic container, and assigning it arolewould 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 isbadge's visually-hidden status<span>(a freshsr-onlyclass, not a selector target). Separately, 3 unlabeled form controls inexamples/demo_projecttemplates —Y003defects 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 anddjust_audita11y reporting — is deferred to follow-up issues. Component-markup guarantees covered bypython/djust/components/tests/test_component_aria.py— 27 new tests.
Fixed
_create_tarballexclude-matching anchored — substring containment dropped legitimately-named files from deploy tarballs (#1505).python/djust/deploy_cli.py's_create_tarballmatched everyTARBALL_EXCLUDESentry via substring containment (pattern in name), so any file or directory whose name merely contained an exclude token was over-excluded —venvdroppedvenvironment.py,distdroppeddistance.py,mediadroppedmedia_helper.py, and similar lookalikes.TARBALL_EXCLUDESis now split into five typed groups —EXCLUDE_DIR_NAMES,EXCLUDE_DIR_SUFFIXES,EXCLUDE_FILE_SUFFIXES,EXCLUDE_FILENAMES, andEXCLUDE_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, soEXCLUDE_FILENAME_STEMSapplies afile == 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.environmentis correctly not excluded. Regression coverage in theTestCreateTarballclass (python/tests/test_deploy_cli.py) — 60 tests in the file.- 4 HTML-attribute regexes in
checks.pyre-anchored to stop false-matchingdata-*attributes (#1514)._ACCESSIBLE_NAME_ATTR_RE,_HREF_ATTR_RE,_IMG_HAS_ALT_RE, and_CONTROL_ID_REused a bare\bword-boundary anchor before the attribute name. Because-is a non-word character,\bmatches inside adata-prefix (betweendata-and the attribute name), so each regex false-matcheddata-*attributes — e.g._IMG_HAS_ALT_REtreated<img data-alt=...>as having a realalt(aY002false negative on a genuinely alt-less image), and_HREF_ATTR_REcould treat<a data-href=...>as a real link (aY001false 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 theY003/Y004regexes — the third occurrence of this\b/data-*defect class. Recurrence is guarded against by a new meta-check test,TestChecksRegexHardeninginpython/djust/tests/test_accessibility_checks.py, which introspects every compiled attribute regex inchecks.pyand fails on any bare-\banchor; the four_LIVE_RENDER_*template-tag-kwarg regexes are allowlisted since they scan{% %}kwargs rather than HTML attributes (#1517). 8 new tests inpython/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**: Acceptedmust record where it shipped via a**Shipped in**: vX.Y.Zline, not a forward-looking**Target version**:line (aTarget versionon 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 aTarget versionline, and emits a soft warning for the inverse drift (aProposed/DraftADR that already names aShipped inversion). Wired into.pre-commit-config.yaml(runs when anydocs/adr/file is staged),.github/workflows/test.yml, and amake check-adr-statustarget. Covered bytests/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 inREADME.mdandQUICKSTART.md— catching malformed snippets (syntax errors) and phantom imports (animportof 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 againstpyproject.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 amake check-doc-snippetstarget. Covered bytests/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 thedjustself-entry recorded insideCargo.lockanduv.lockmatches 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 themake version,make release, andmake version-checktargets,.github/workflows/test.yml, a.pre-commit-config.yamlhook (runs when a lockfile or manifest is staged), and documented inRELEASING.md. Covered bytests/test_check_lockfile_versions.py— 6 tests.- Two
mandatory:falseStage-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 specificrolevalues, and to grep constraint tables before labeling dependencies — both internal contributor-process guidance. - Two new
Yaccessibility system checks —Y003/Y004(#1496). Extends theYcategory (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>, anaria-label, or anaria-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 (adata-typeattribute is not mistaken for the inputtype). Y004 flags a positivetabindexvalue — a WCAG 2.4.3 focus-order anti-pattern;tabindex="0"/tabindex="-1"and interpolated values are valid and not flagged (adata-tabindexattribute is not mistaken fortabindex). Both emit aDjustWarning(never an error) and are suppressible viaDJUST_CONFIG['suppress_checks']orSILENCED_SYSTEM_CHECKS. Implemented inpython/djust/checks.py; covered by theTestY003CheckIntegrationandTestY004CheckIntegrationclasses inpython/djust/tests/test_accessibility_checks.py— 26 tests.
Fixed
- 12 ADRs' stale
Target versionmetadata corrected to match reconciledStatus(#1493). ADRsdocs/adr/002–008and013–017carried**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 markedpost-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 newscripts/check-adr-status.pyaudit (see Added) prevents this drift class from recurring. - Orphaned
TARBALL_EXCLUDESconstant wired into_create_tarball— CodeQL #2330py/unused-global-variable(#1495).python/djust/deploy_cli.pydefined aTARBALL_EXCLUDESconstant with a# Default patterns to exclude from tarballcomment, but_create_tarballignored 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 byin, not glob — a leading*would never match). This is an intended behavior change: deploy tarballs created by_create_tarballnow also exclude.hgand.svndirectories,logs/,media/, andstaticfiles/directories, and.logfiles (the old inline*.logentry never matched, due to the literal*, so.logfiles 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 newTestCreateTarballclass (python/tests/test_deploy_cli.py) — 3 tests, all of which fail if the constant wiring is reverted. - Empty
except: passincheck_psycopg3_for_pg_notifydocumented — CodeQL #2334py/empty-except(#1495). A bareexcept Exception: passinpython/djust/checks.py(the psycopg2__version__read guard) was the only pass-onlyexceptin the file lacking an explanatory comment. It is now replaced with an explicitpsycopg2_version = ""fallback assignment plus a comment explaining the guard:getattralready supplies a""default, so the block only fires on a pathological__version__descriptor, andpsycopg2_versionkeeping its""("version unknown") value is the correct, intentional fallback. No behavior change. - README roadmap reconciliation — 2 stale checkboxes flipped + a broken
register_componentexample corrected (#1497). The README roadmap had two unchecked items — Redis-backed session storage and horizontal scaling — that both shipped viaRedisStateBackend; their checkboxes are now ticked. Aregister_componentexample snippet was also broken: it imported from the wrong package and used aComponentbase class thatregister_componentrejects. The snippet is corrected to import and subclassLiveComponent(execution-verified).docs/roadmap.mdcarried no mechanical rot and is left unchanged. - Stale
djustself-entry inuv.lockcorrected0.9.7→1.0.0rc1(#1498, #1487). Theuv.lockdjustpackage self-entry still recorded0.9.7after thepyproject.tomlbump to1.0.0rc1, so a fresh resolve installed mismatched package metadata. The newscripts/check-lockfile-versions.pyaudit (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-leveldjustexports, thedjust.decoratorsdecorators, publicLiveView/LiveComponent/Componentmethods, 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 —DeprecationWarningannouncement,.. deprecated::docstring marker,### DeprecatedCHANGELOG entry, mandatory migration path — and the support window: a symbol deprecated in1.Yis removed no earlier than2.0.0, with a>= 1.1.0removal floor for the three pre-1.0 legacy symbols (@event,LiveViewForm, the_legacytheming module). A user-facing companion guide ships atdocs/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_deprecateddeprecation helper —djust._deprecation(v1.0.0 milestone, unit 2). A single, standardized way djust emits a runtimeDeprecationWarning.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 existingDeprecationWarningcall sites (@event,LiveViewForm, the_legacytheming module) now route through it. Yaccessibility 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 noaria-label/aria-labelledby/title— a screen-reader user hears nothing for such a control. Y002 flags an<img>tag with noaltattribute (WCAG 1.1.1, Level A);alt=""(decorative image) is correct and not flagged. Both emit aDjustWarning(never an error, so a stray false positive cannot failmanage.py check) with the file path and line number, and both are suppressible viaDJUST_CONFIG['suppress_checks'](or Django'sSILENCED_SYSTEM_CHECKS). Templates that show literal HTML inside{% verbatim %}blocks are skipped. The category is the foundation — the scan plumbing exists, so addingY003+ (heading order, form-label association,langattribute) later is a single-function-body change. Implemented as acheck_accessibilityfunction inpython/djust/checks.py; covered bypython/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.
modalgetsrole="dialog"+aria-modal="true",aria-labelledbyto the title, andaria-label="Close"on the close button.tabsgetsrole="tablist"/role="tab"/role="tabpanel"witharia-selectedandaria-controls/aria-labelledbypairing.accordiongetsaria-expanded/aria-controlson triggers androle="region"/aria-labelledbyon panels.dropdowngetsaria-haspopup="menu",aria-expanded,aria-controls, androle="menu".alertgetsrole="alert"(error/warning) orrole="status"(info/success) andaria-label="Dismiss".paginationgetsaria-label="Pagination"on the nav,aria-current="page"on the active page, andaria-labels on page/arrow buttons.data_tablegets keyboard-focusable (tabindex="0") sortable column headers.toastgetsrole/aria-live(assertive for errors, polite otherwise) andaria-label="Dismiss". Decorative glyphs and icons are markedaria-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 pairingids are derived deterministically from existing kwargs, keeping VDOMdj-idstable. New guide atdocs/website/guides/accessibility.md; component-markup guarantees covered bypython/djust/components/tests/test_component_aria.py.
Fixed
- Stale and vague deprecation-warning messages corrected (#1483-adjacent, v1.0.0 milestone, unit 2). The
LiveViewFormdeprecation 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.0removal floor. The@eventdecorator and the_legacytheming module previously named no concrete removal version ("a future release" / no version at all); both now name the>= 1.1.0floor. Additionally, all three deprecation warnings now point at the caller's frame rather than djust's own internal frame — thestacklevelvalues were corrected for the newwarn_deprecatedwrapper depth (including the metaclass-chain frames Django'sDeclarativeFieldsMetaclassadds for theLiveViewForm.__init_subclass__path), sopython -Wand pytest report the warning at the application code that triggered it. - Rust template renderer now supports Django's
is/is notidentity operators in{% if %}conditions (#1483).{% if x is None %}and{% if x is not None %}previously fell through every operator branch inevaluate_conditionto the defaultOk(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 implementsis/is notwith Python identity semantics: identity holds only for the singletonsNone,True, andFalse; 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-Nonevalue — will now correctly take the{% if %}branch. This brings the Rust template engine to parity with the Django (Python) engine, which has supportedis/is notnatively since Django 4.0. Regression coverage inTestIsIdentityOperators(python/tests/test_template_conditions.py) plus Rust unit tests incrates/djust_templates/src/renderer.rs.
Security
- Pre-1.0 dependency security sweep (v1.0.0 milestone, unit 3). Refreshed
uv.lockto 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) + 150msasyncio.wait_fordefense-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 inhandle_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_sessionviasync_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 beyondsend_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 withgetattr(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 inasyncio.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 inpython/djust/tests/test_ws_reconnect_state_1465.py: opt-out integration test via realWebsocketCommunicator(load-bearing negative assertion that session key stays absent), source-pin for the AND'd gate, andasyncio.wait_forsemantics 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.shandmake commit MSG="..."target. Runsuvx pre-commit run --files <staged>first; if hooks (ruff-format, ruff --fix, etc.) rewrote staged files the wrapper computes a per-file hash diff andgit adds only the files whose content actually changed, then proceeds togit commit. Eliminates the ruff-bounce friction class where vanillagit commitexits 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. Baregit commitpath 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 agit add -ppartial stage. Post-commit Action #122 verification (git rev-parse HEADadvanced) is built in. Pre-flightgit rev-parse --git-dircheck gives a clean exit-1 outside a repo. macOS bash 3.2 compatible (nodeclare -A, nomapfile -d). 9 regression tests intests/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 topython/djust/websocket.pyclose a long-standing gap whereenable_state_snapshotwas effectively HTTP-only. (1)handle_eventnow mirrors the HTTP-path session save (mixins/request.py:603-609) after every successful WS event handler — freshget_context_data()snapshot is filtered forLiveComponentinstances, normalized, andasetto the Django session underliveview_<page_url>, with private (_-prefixed) attrs and components persisted alongside. (2) The load gate inhandle_mountwidens fromif has_prerendered:toif has_prerendered or saved_state:so pure-WS reconnect (no SSR — e.g. after djustlive proxy force-closed the backend WS for snapshot) reaches theaget(view_key)lookup. (3) Mount response now skips thehtmlfield on resume — whenmounted(state restored) ANDhas_prerendered, the client's DOM already reflects the saved state, so omittinghtmlprevents a redundant morphdom-style DOM swap;client.js'se.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 viaenable_state_snapshot/has_prerendered— behavior for non-opt-in views' load path is unchanged. The SAVE block inhandle_event, however, fires unconditionally after every successful WS event, mirroring the HTTP-pathmixins/request.py:603-609semantics — the same session-write cost a POST incurs. Downstream consumers with high-event-rate views (e.g.dj-inputper 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.mjspreviously caught only direct top-level reads of late-declaredlet/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 ifdecl.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 overrideBUNDLE_SRC_DIRfor synthetic-bundle tests. The runtime regression testtests/js/bundle-init-no-tdz.test.jsremains the simulate-bundle-init safety net — the two checks are complementary. Currentmainbundle 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.messagevariant (wire-distinct fromerror.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 entiresend_jsonwire 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 + withreset_form/event_nameconditional appends),connect. 6 new tests, 25 total inpython/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.pywithmount_batch(envelope + optionalnavigateappend),child_update,sticky_update,sticky_hold(with views + empty-list drop-all signal), andembedded_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/VNodesnapshot pinning to the Pythonsend_jsonemit sites.python/djust/tests/test_wire_protocol_snapshots.pypinspush_event,flash,page_metadata,patch(envelope),mount(with + withoutpublic_state),layout,navigation(inner-type-to-actionpromotion), anderroragainst 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_stateandpatch.htmlare 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 onmain. Test count: djust_vdom 248 → 272.- #1413 —
proptest_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, assertsassert_handles_resolveinvariant on every cycle. - #1416 —
torture_html_round_trip.rs: 8 scenarios exercising the liveVDOM → to_html → parse_htmlround-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). - #1417 —
test_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. - #1418 —
torture_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. - #1420 —
torture_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).
- #1413 —
[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)—RedisStateBackendZstdCompressor/ZstdDecompressor moved tothreading.local(#1430 / #1431). The previous shared-instance shape producedZstdError, "Data corruption detected", and outright SIGSEGV insideZSTD_decompressSequencesLong_defaultunder 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 whenRustLiveView.deserialize_msgpackraised after a hot-swap struct change.feat(checks)—djust.D001system check for Postgres-configured-without-psycopg[binary]>=3.2misconfig (#1433 / #1440).perf(theming)—theme_contextnow caches its output byThemeStatetuple AND pre-renderstheme_panel/theme_mode_toggle/theme_preset_selectoras context strings (#1435 / #1437 / #1442 / #1443). Per-request cost ~1-3 ms → ~5-10 µs post-warmup on theme-rendering pages.perf(tenants)—TenantMiddlewareshort-circuits when no resolver is configured (#1436 / #1441). Saves ~2-5% per-request CPU fordjust[tenants]deploys without tenant opt-in.feat(deploy)—djust deployCLI 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; noclient_secret; refresh-token rotation.test(vdom)— wire-protocol JSON snapshot tests for everyPatchvariant +VNodestruct (#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_outputshand-built a small string fortheme_headthat dropped the<link>todjust_theming/css/components.css(where.theme-panel*rules live), theprint.csslink, thecomponents.jsscript, the deferred-CSS preload, the RTLdirectionattribute, and the cookie-namespace JS prefix. Production saw unstyled theme panels because components.css never loaded. Fix: route{{ theme_head }}through the existingtheme_headsimple_tag (same shape #1443 already adopted fortheme_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_contextnow pre-renderstheme_panel,theme_mode_toggle, andtheme_preset_selectoras 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'sbase.htmlhad{% 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_contextnow caches its rendered output byThemeStatetuple (#1437). The Django context processordjust.theming.context_processors.theme_contextpreviously ran the full CSS-generation + theme-switcher HTML pipeline on every templating request. Output is nowlru_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. Newdjust.theming.context_processors.clear_theme_context_cache()exposed for theme-pack hot-reload and tests.TenantMiddlewareshort-circuits when no resolver is configured (#1436). When neitherDJUST_CONFIG['TENANT_RESOLVER']norDJUST_TENANTSis set, the middleware now bypasses the resolver call, the thread-local set/clear pair, and the required-tenant gate — switching__call__to a straightget_response(request)passthrough. Saves ~2-5% per-request CPU for consumers withdjust[tenants]installed but no tenant opt-in (single-tenant deploys, scaffold starters, demo apps). Consumers who set either config keep the full path;request.tenantis still set toNoneon the no-op path sogetattr(request, "tenant", None)callers see the same shape.
Added
- System check
djust.D001— warn when Postgres is configured butpsycopg[binary]>=3.2is not installed (#1433). djust'sdb.notifications(LISTEN/NOTIFY bridge) requires psycopg3. The 0.9.5 cycle hardened the runtime path to permanent-fail with a WARNING when@notify_on_saveactually fires (#1357), but operators who deploy the misconfig without an active consumer wouldn't see the warning until much later. D001 surfaces it atmanage.py check/runserverstartup, before traffic. Fires only when the default DB engine is Postgres ANDpsycopg2is importable ANDpsycopg(3.x) is missing or at version < 3.2. Silenceable per-project viaSILENCED_SYSTEM_CHECKS = ['djust.D001'].
Fixed
RedisStateBackendnow uses per-threadZstdCompressor/ZstdDecompressor(#1430).zstandard.ZstdCompressorandZstdDecompressorare NOT thread-safe (python-zstandard #244, closed "by design"). The previousRedisStateBackend.__init__stored a single instance of each onself, so concurrent callers raced on the C-level state. Symptoms ranged fromZstdError("Unknown frame descriptor")and "Data corruption detected" to outright SIGSEGV insideZSTD_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 athreading.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). WhenRustLiveView.deserialize_msgpackraised — 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 acargo buildofdjust_vdom+.soswap, fresh navigations could re-render with state from the prior session. Now the backend pops the corrupt entry from its in-memory dict and returnsNone; the caller's mount path treats the cache as cold and runsmount()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 singledjust deployinvocation: 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 topyproject.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/-yauto-accepts every confirmation (CI / scripts),--no-createfails fast if the project doesn't exist server-side and propagatesinteractive=Falsethrough the slug-resolution + login chain so CI runs with no creds and no slug exit instead of prompting.djust deploylogin 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 (noclient_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 triesrefresh_tokenbefore re-launching the browser, so weeks-apart deploys don't bounce the user. Loopback callback HTML emitsReferrer-Policy: no-referrer+Cache-Control: no-storeto keep the auth code out of any future Referer header or browser/proxy cache (RFC 8252 §8.10). State parameter compared withsecrets.compare_digest.--server/DJUST_SERVERenforceshttps://except for127.0.0.1/localhostdev 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_idsis 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-cyclesync_idsround-trip torture (#1412 / #1414) exercising the production server loop (diff→ apply on a faithful client tracker →sync_ids→ store aslast_vdom). Catches the #1408 regression class locally oncargo test.test(vdom)— shared test harness extracted tocrates/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_idsround-trip torture (#1412 — regression-class hardener for #1408). Addedcrates/djust_vdom/tests/torture_round_trip_with_sync.rsexercising the production server loop (diff→apply_patcheson a faithful client tracker →sync_ids→ store aslast_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-awareapply_allpatch applier, the cross-renderassert_handles_resolveinvariant checker, and the sequentialIdGendj-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_idsis now dj-if-boundary-aware (#1408).diff::diff_childrenalready aligned children across<!--dj-if id="…"-->boundary swaps viadj_if_pre_pass(#1358); the post-diffsync_idsdid not, falling through tosync_ids_keyed/sync_ids_indexedand positionally pairing children regardless of branch identity. After a{% if %}branch swap, freshdjust_ids on new-branch content were overwritten by stale ids from the unmatched-old-branch — the next render's diff then emittedRemoveChild/SetAttrpatches whosechild_d/dreferenced ids the client DOM never had, leaving orphan content from the prior branch. Addedsync_ids_dj_if_pre_passmirroringdj_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 viabuild_excluded_mask). Verified at theDJUST_VDOM_TRACE=1level: pre-fix a tab-swap diff emittedRemoveChild child_d="2y"(a stale id from a render two cycles back) for the bottom-tab content slot; post-fix it correctly emitschild_d="5m"matching what the previous diff'sInsertChildplaced there. Reproduced and verified-fixed against a downstream consumer's bottom-tab-swap reproducer. Regression test incrates/djust_vdom/tests/test_sync_ids_dj_if_1408.rs(4 cases; 3 fail pre-fix). All 228 existingdjust_vdomtests 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-declaredlet/constis referenced directly at top level of an earlier module. Enumerates module-scopelet/constacrosspython/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()callingmountHooks()whose body reads a latelet— this is exactly the #1370 shape). The runtime regression testbundle-init-no-tdz.test.jscontinues 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 intoMakefile(make check-bundle-init-orderis part ofmake check) and pre-push hook. Currently clean on main.
Changed
- JS micro-cleanup: deduplicated transition helpers + tightened
routeMapaccess (#1360, #1361). Two follow-ups deferred from PR #1359 Stage 11._parseTimeMsand_computeTransitionTimingextracted from41-dj-transition.jsand42-dj-remove.jsinto a new shared40a-transition-helpers.js(loads before both consumers per the bundle's lexicographic concat order). CodeQL alerts atclient.js:13162and:13171("Conflicting function declarations") clear; bundle has exactly one definition of each (#1360).routeMap[pathname]access in18-navigation.jsreplaced with anObject.entries(routeMap)walk — prototype-pollution-immune by construction (own enumerable string-keyed entries only). Lints cleanly withouteslint-disable-next-line. Same shape applied to46-state-snapshot.js. Map conversion (option B) rejected because it would change the wire-protocol shape emitted bypython/djust/routing.pyand 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 siblingget()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 frompython/djust/state_backends/memory.py. (Surfaced as PR #1355 Stage 13 Re-Review #1.)
Fixed
Node::Includeround-trip no longer double-quotes the template path (#1396). Parser was preserving the outer quotes onInclude.template; emitternodes_to_template_stringthen 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 existingExtends,Static, andNowcontracts) — single source of truth, emitter unchanged.test_nodes_to_template_string_includeun-ignored. Addedtest_nodes_to_template_string_nowfor defense-in-depth.
Security
sanitize_for_logcache_key on HTTP cache-lookup debug log (#1368). Pre-existing log-injection asymmetry between WebSocket and HTTP paths inpython/djust/mixins/rust_bridge.py: the WS site at line 333 sanitized correctly; the HTTP site at line 363 did not. Sincecache_keyderives fromrequest.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 tosanitize_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 viacls.__dict__.get(avoids attribute inheritance). Refactored the existing snapshot-truncation warning inpython/djust/websocket.pyto 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.jsonStage 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.jsonStage 7: mandatory checklist item requiring disconfirming citations during self-review — bias toward active falsification rather than passive confirmation (#1386).CLAUDE.mdBug-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_attrssnapshot-order invariant note (#1393).CLAUDE.mdProcess 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.pynow verifies Makefile andpyproject.tomltestpaths 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 leftpython/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.mdanddocs/audits/decorator-contract-2026-05.mdnow 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_baselinealready runs in production atpython/djust/websocket.py:2145./djust-dev audit-statuswill 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.rsmigrated from manually-constructedNode::*ASTs toparse(tokenize(source)). Previously, manual construction bypassed parser invariants likeparse_filter_specs's outer-quote preservation, so contract violations silently passed (the original PR #1086 / #1081 case). Conversion uncovered a previously-masked bug innodes_to_template_stringfor{% 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_attributeand_class_defines_methodaccept an optionalclass_indexparameter and walk the same-module MRO via static analysis when supplied. The X008 IDOR-shape checker uses this so views inheritingpermission_requiredfrom a base mixin (or inheritinghas_object_permission/check_permissionsoverrides) 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_staticprevents recursion onclass A(B): ...; class B(A): ...._mount_assigns_url_kwarg_idnow recognizes three additional RHS shapes beyond bareself.x = x:self.kwargs["x"](Subscript),int(x)/str(x)/uuid(x)/UUID(x)(whitelisted casts; literal arguments likeint(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
TestX008IDORShapeNeedsObjectPermissioncover 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'sowner_requestisNone(the parent failed to stamprequestbecausemixins/sticky.py:212-218's read-only-child constraint raisedAttributeError),_validate_event_securitynow FAILS CLOSED if the child opted into the object-permission lifecycle: sends apermission_deniederror frame and logs aWARNINGinstead of returning the handler. Views that did NOT overrideget_objectare unchanged (no security check is active for them, so silent fall-through is correct). Companion change:mixins/sticky.py:215logger.debug→logger.warningon 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 onLiveView— 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 checkIDOR-shape heuristic,authorization.mdguide,djust-devskill 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 lookupModel.objects.get(pk=self.<x>_id)). Default returnsNoneso views that don't override see zero behavior change.has_object_permission(self, request, obj) -> bool— returnTrueif the request user may accessobj. Default returnsTrue. Called at mount-time whenget_objectis 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 cachedself._objectwould let a formerly-authorized user retain access until WS reconnect.
The framework caches the result of
get_object()asself._objectafter mount — reuse it from event handlers andget_context_datarather 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()returnsNone,has_object_permissionis not called (the caller raises 404 if it wants to). The framework also catches Django'sObjectDoesNotExist(parent of everyModel.DoesNotExist) ANDdjango.http.Http404(raised byget_object_or_404) insidecheck_object_permissionand treats them asNone— automating the 404-shape pattern so a naiveModel.objects.get(pk=missing)orget_object_or_404(...)doesn't leak existence viaDEBUG=Truetraceback. Note the two are listed as separate catches becauseHttp404inherits fromExceptiondirectly, not fromObjectDoesNotExist.Order of auth checks (logical onion):
login_required→permission_required→check_permissions(existing) →has_object_permission(NEW). The new step has its own physical call site atwebsocket.py:handle_mountpost-mount (not insidecheck_view_auth), becauseget_object()readsself.<x>_idpopulated by the user'smount()body —check_view_authruns pre-mount whenself.kwargsisn't yet bound. ADR-017 § Decision 5 documents the rationale.New helpers in
djust.auth.core:check_object_permission(view_instance, request)— re-exported fromdjust.auth. Wiresget_object+has_object_permissiontogether; raisesPermissionDeniedon 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 atwebsocket.py:1953-1955.Backwards compatible: views that don't override
get_objectsee zero behavior change (verified empirically — full pytest suite of 4670 tests + 1563 JS tests passes unchanged). Apps that already usecheck_permissionskeep working; the new step runs after.9 regression tests in
tests/integration/test_object_permission_mount.py: denial viareturn FalseraisesPermissionDenied; allow populatesself._object; no-override is a no-op;_invalidate_object_cacheresets the cache (verified via call counter);get_object()=Noneskipshas_object_permission;get_object()raisingObjectDoesNotExistis treated asNone;get_object()raisingHttp404is treated asNone(defense-in-depth against theDEBUG=Truetraceback leak);has_object_permissionraisingPermissionDenieddirectly (vsreturn 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_permissionis called (locks the strict-identityis Nonecontract). -
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 inwebsocket.py, plus HTTP-runtime inruntime.pyand SSE insse.py— five call sites total). Adding the check there covers all transports without per-site changes.Per-event denial semantics:
has_object_permission(...)returnsFalse→PermissionDeniedraised bycheck_object_permission→ caught by_validate_event_security→send_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()returningNoneor raisingObjectDoesNotExist/Http404→ no denial, handler proceeds (consistent with mount-time semantics; the developer'sget_object()already implements the OWASP 404-shape).- View doesn't override
get_object→_has_custom_get_objectshort-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 structuredcodefield 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_permissionnow setsself._object = objonly AFTERhas_object_permissionreturnsTrue— never on denial, never on DNE/Http404 (those reset toNone). 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._objectis allocated inLiveView.__init__BEFORE the_framework_attrssnapshot, so it's classified as a framework slot and excluded from msgpack-serialized user-private state. After WS reconnect / state-restore, the cache isNoneandget_object()re-runs fresh — handles "object reassigned while user was disconnected" automatically. New regression testtest_object_cache_is_framework_slot_excluded_from_user_statelocks this contract.Embedded child views (
{% live_render %}): when an event targets a child view viaview_id, the dispatch sites pass the resolvedtarget_view(the child) to_validate_event_security. The check uses the CHILD'sget_object/has_object_permission, NOT the parent's. New regression testtest_embedded_child_view_uses_child_get_objectverifies.Fail-closed on developer-code exceptions (Stage 11 🟡 finding): if
get_object()orhas_object_permission()raise anything other thanPermissionDenied(e.g., anAttributeErrorin 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_objectoverride see zero behavior change. Existing handler-level@permission_requireddecorators 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 checkheuristic —X008(python/djust/audit_ast.py). Flags any view matching the IDOR shape: extendsLiveView(or matches the existing detail-view heuristic), haspermission_requiredset,mount()assigns from a URL kwarg ending in_id(the canonicalself.document_id = document_idpattern), at least one@event_handler-decorated method readsself.<that>_id, AND does NOT overridehas_object_permissionorcheck_permissions. Severity: warning. Details point todocs/website/guides/authorization.mdfor the migration recipe. Distinct from existingX001(.get(pk=user_input)pattern);X008is structural — it flags the shape regardless of fetch mechanism. Runpython manage.py djust_audit --astto find matches. -
New guide
docs/website/guides/authorization.md. Walks through the four-layer auth onion (login → role → custom → object), the canonicalget_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-eventcode: permission_denied), defense-in-depth via manager-levelfor_user()filtering, and a worked migration example (before/after diff for hand-rolledget_context_dataIDOR checks). -
djust-devskill principle catalog updated. Two new entries: "Object-level authorization (post-v0.9.5)" with the canonical pattern, OWASP rationale, cache discipline, migration recipe, anddjust check X008reference; and "Security-class code defaults to fail-closed at every catch block" — when implementing auth/permission/validation code, catchException(not just the specific expected error), log vialogger.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_permissionoverride OK,check_permissionsoverride OK, nopermission_requiredno 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 emitdj-ifboundary 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 innerIf(B)inside the outer'sfalse_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. Thecond=attribute is intentionally OMITTED for safety (condition strings could contain--or>that would close the comment early; Iter 3's differ keys off theidalone).{% 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>-Nassigned at parse time viaparser::assign_if_marker_idswalking 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 oneNode::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 acceptsdj-if,dj-if<space-or-tab>..., and/dj-if; it rejects lookalikes likedj-iffy,dj-if-extra,dj-ifid="...". Client- sidegetNodeByPathpath-fallback (12-vdom-patch.js) now mirrors this predicate via the newisDjIfCommenthelper, keeping client and server in lock-step. Publicrender_template/render_template_with_dirsstrip ALLdj-if-family markers viastrip_dj_if_markershelper — 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/InsertSubtreepatch types. - Iter 3 (Capability): Rust VDOM differ recognizes
dj-ifboundaries; 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 undercross_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::testsfor prefix-deriving functions (parse_with_sourceshape / source-distinctness / source-stability / token-fallback). - 25 cases in
python/tests/test_template_if_markers.pyacrossTestElementBearingIfMarkers/TestPureTextSkip/TestPublicRenderTemplateStrips/TestIdStability/TestIdAssignment/TestAttributeContext/TestSiblingStability/TestIdPrefixUniqueness/TestCsrfTokenElementBearing. - 20 cases in
tests/js/dj_if_comment_predicate.test.js(predicate matrix + path-fallback integration).
- Iter 2 (Foundation 2): client patch applier learns
-
Client VDOM patch dispatcher learns
RemoveSubtree+InsertSubtreepatch 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 recognizesdj-ifboundaries (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 viaTreeWalker-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 atparent[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 fromapplySinglePatchvia a short-circuit ahead of the path/d resolution so subtree patches don't try to resolve a non-applicable path. 25 regression cases intests/js/dj_if_subtree_patches.test.jscoveringextractDjIfMarkerId(5) + marker-pair finder + nesting (5) +RemoveSubtreepositive / empty-inner / nested-outer-removes-inner / nested-inner-leaves-outer / id-not-found / root-pair / missing-id (7) +InsertSubtreeparses-and-inserts-at-index / appends-on-out-of-range / inert-script-via-template / missing-html / unresolvable-parent (5) +applySinglePatchdispatch wiring (3).What this enables (NOT in this PR): Iter 3 (Capability): Rust VDOM differ recognizes
dj-ifboundaries 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 not →
RemoveSubtree { id: X }. Client locates the marker pair by id (NOT by position) and removes the bracketed range. - NEW has boundary id=Y, OLD does not →
InsertSubtree { id: Y, path, d, index, html }. Client parses the full marker-pair HTML (Shape A) via inert<template>.innerHTMLand 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 viadiff_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+InsertChildpatches 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_innerrecursively runs on the body slice. Each recursion level handles only its OWN top-level pairs (via the newfind_top_level_dj_if_pairshelper), 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-noneworkaround 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 forInsertSubtree.html),build_excluded_mask. - New
dj_if_pre_passruns atdiff_childrenentry; delegates todj_if_pre_pass_innerwhich 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),Noneotherwise (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-499and JS-side atpython/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-keyattributes 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 todiff_keyed_childrenwhen 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-noneworkaround documentation in CLAUDE.md / downstream repos.Regression suite: 19 cases in
crates/djust_vdom/tests/test_dj_if_keyed_diff_1358.rscovering: 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 viaserde_json::ValueforRemoveSubtree/InsertSubtree/domission when None (tightened from substringcontainsper Stage 11 finding), backward compat with legacy bare<!--dj-if-->placeholder, end-to-end viaparse_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.
- OLD has boundary id=X, NEW does not →
Fixed
-
_sortPatchesnow ordersRemoveSubtree/InsertSubtreeBEFORE path-based child ops — the actual root cause of #1370._sortPatchesassigned 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-basedRemoveChild/InsertChildindices reflect the NEW tree's positions (after subtree ops applied). RunningRemoveChildagainst the still-old DOM targeted the wrong child → silent DOM corruption that accumulated across tab switches until client and server state fully desynced. Fix: assignRemoveSubtreephase -2 andInsertSubtreephase -1, so both sort ahead ofRemoveChild(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/InsertSubtreeare 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 aRemoveSubtreefor a marker already removed in a prior patch (or anInsertSubtreefor 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.RemoveSubtreewith a missing marker is a no-op (returns true);InsertSubtreewith an already-present marker is a no-op (doesn't duplicate content). Symmetric. Matches the semantics ofRemoveChildon 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) withliveview_html. Single dj-root level, correct marker IDs fromself._rust_view, no path index drift. -
Handler metadata
<script>no longer injected insidedj-root(#1370 final fix)._inject_handler_metadatawas appending a<script>element inside thedj-rootcontent 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_templatenow rendersdj-rootcontent viaself._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'sdj-rootinnerHTML is replaced withself._rust_view.render(). No marker stripping, no first-WS overhead, no mismatch possible. Removes the architectural debt of twoRustLiveViewinstances rendering the same view. -
Marker ID mismatch between HTTP render and WS diff resolved (#1370 re-open).
render_full_templatecreated a temporaryRustLiveView(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 WSrender_with_diff, the differ sees "NEW has markers, OLD doesn't" → emitsInsertSubtreewith the correct (VDOM-tracked) IDs. The non-inheritance path (self.render()) was already correct (sameRustLiveViewinstance as WS path). This only affected projects using{% extends %}template inheritance. -
RemoveSubtree/InsertSubtreepatches no longer crashgroupPatchesByParent(#1370 follow-up). v0.9.4rc2 fixed the hooks TDZ but exposed a second crash:TypeError: Cannot read properties of undefined (reading 'slice')atgroupPatchesByParentin12-vdom-patch.js. The Iter 3 patches (RemoveSubtree,InsertSubtree) don't carry apathfield — they locate their target by markerid.groupPatchesByParentassumed all patches havepath. Fix: filter out id-based patches and apply them directly viaapplySinglePatchBEFORE 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.jsthat threwUncaught ReferenceError: Cannot access 'G' before initialization(Gis 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 _activeHookswas in TDZ when_ensureHooksInitwas invoked from earlier modules'djustInit(the synchronous-init branch fires whendocument.readyState !== 'loading'). Fix:let→varfor_activeHooksand_hookIdCounterinsrc/19-hooks.js:54-56(hoisted, no TDZ). Bundle rebuilt; new regression test intests/js/bundle-init-no-tdz.test.js(2 cases) loads the bundledclient.jsin a fresh JSDOM context withreadyState === 'complete'and asserts noReferenceErroron 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;_activeHooksis the inverse (DECLARED-LATE-USED-EARLY in the concat). The new bundle-init regression test catches the class structurally. -
dj-transitionnow respects CSStransition-durationinstead of a hard-coded 600ms fallback (#1348). The fallback timeout is auto-derived from the element's computedtransition-duration+transition-delay(longest pair across all transitioning properties) plus a 50ms grace window. For multi-property transitions, expectedtransitionendevents are counted fromtransition-propertyand 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 todj-remove. Source-only commit; bundle (client.js) rebuild deferred per #1351 (392 pre-existing eslint warnings block--max-warnings 0). -
db.notificationsexits cleanly on permanent failures instead of retrying forever. Background — incident 2026-05-05: a 3.5-day-old djust deploy missing the optionalpsycopg[binary]>=3.2dependency 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()raisesDatabaseNotificationNotSupported(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 inpython/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 cachedRustLiveView(the in-memory backend returned the same Python reference on cache hits), concurrent&mut selfRust methods on the shared view would collide inside Rust'sRefCell::borrow_mutand surface asRuntimeError: Already borrowed(a downstream consumer observed 17.5% 500-rate at concurrency 2). The race spanned more than the_sync_state_to_rustmutation calls —render()itself holds&mut selfacross template evaluation, andContext::resolve_dotted_via_getattr(crates/djust_core/src/context.rs) wrapsPython::with_gilso the embeddedgetattrcan yield the GIL inside an active mutable borrow. Any peer thread entering an&mut selfmethod during that window panicked. Fixed by switchingInMemoryStateBackend.get()to return an isolatedserialize_msgpack/deserialize_msgpackclone of the cached view (option 2 of three suggested in the issue body), mirroring theRedisStateBackendcontract — which already deserialized fresh on every read. With each caller holding its ownRustLiveViewinstance, no two threads can share a Rust&mut selfborrow and the race class is eliminated at the source. No Python-side lock is needed. New regression cases inTestInMemoryGetReturnsIsolatedView(4 cases — clone identity, state preservation, mutation isolation, concurrent get) andTestConcurrentRenderNoBorrowError(2 cases — concurrent render with GIL-yielding sidecar, concurrent update_state) inpython/tests/test_rust_bridge_concurrent.py. -
State backend honours top-level
DJUST_STATE_BACKEND/DJUST_REDIS_URLsettings (#1354). PreviouslyBackendRegistryonly consultedDJUST_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 ofDJUST_CONFIG(DJUST_CONFIGstill wins when both are set — backwards-compatible). URL-shaped values (redis://,rediss://,redis+sentinel://) are auto-translated tobackend_type="redis"plusREDIS_URL=<url>; the prefix list lives inBackendRegistry._REDIS_URL_PREFIXES. WhenDEBUG=Falseand the resolved backend is the default (in-memory),djust.utils.BackendRegistry.getnow emits alogger.warningflagging the production misconfig — multi-process deployments lose state across replicas.unix://URLs are left as a TODO follow-up because the underlyingredis-pyclient takes Unix sockets via a different parameter name. New regression cases inTestTopLevelStateBackendSetting(6 cases) andTestDjustConfigRegression(3 cases) inpython/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 cachedRustLiveViewstate 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 fromparse_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 candjust clear --allfor 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 singledjust_templates::parser::template_hash_hexRust helper, so they cannot drift. 12 regression tests inpython/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 theget_template()cost). 3 new Rust unit tests incrates/djust_templates/src/parser.rs(hash consistency, distinguishability, marker-ID prefix equality). Existingtest_vdom_cache_key.pyupdated 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 calledget_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 touchingget_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 bytest_compute_template_hash_stable_across_rebuilds. Stage 12 rewrites it to set up realparent.html+child.htmlfiles, rewritechild.htmlbetween 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).
- Cache HIT perf-regression fix. First implementation hoisted
-
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.jsanddebug-panel.jsare now eslint-clean (#1351). The 393 pre-existing eslint warnings inclient.js(and 32 indebug-panel.js) have been resolved across the ~70 source modules inpython/djust/static/djust/src/andsrc/debug/. Breakdown of the fixes:- Auto-fixed: 222 (
prefer-const,no-var) viaeslint --fix python/djust/static/djust/src/. - Targeted disables: 116 in
client.jssources + 25 indebug-panel.jssources (security/detect-object-injection, all on internal data structures — typed for-loop indices, controlled object-literal lookups, DOM-controlled keys likefield.name. djust already validates againstUNSAFE_KEYSfor 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). 1security/detect-non-literal-regexpfor server-controlled route patterns in18-navigation.js. - Cross-module guards: 4
prefer-constreverted toletfor 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 0flag is now enforced on the eslint pre-commit hook (.pre-commit-config.yaml) — contributors no longer needSKIP=build-js,eslintto commit JS source changes. Unblocks the bundle rebuild deferred from PR #1357 (dj-transition fix #1348).
- Auto-fixed: 222 (
[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--adminmerge 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_onenow returns a consistent 5-tuple from every path (#1343). Theexcept Exceptionbranch inLiveViewConsumer._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 inhandle_mount_batchunpacks 5 values; the mismatch raisedValueError: not enough values to unpack, masking the per-view error in the batchfailed[]plumbing. Surfaced by CodeQLpy/mixed-tuple-returnsalert. Returns[]forpush_eventsfrom the exception path. 1 regression test intest_sw_advanced.py::TestMountBatch::test_mount_one_returns_5_tuple_on_unhandled_exception. -
deploy_cli.pyno longer has a bareexcept: passfor transient status-poll errors (#1343). Surfaced by CodeQLpy/empty-exceptalert. Replaced withlogger.debug("status poll failed; retrying", exc_info=True)- an explanatory comment. CLAUDE.md security rule #5 forbids bare
except: passframework-wide.
- an explanatory comment. CLAUDE.md security rule #5 forbids bare
-
python/djust/tests/now included inmake test-python+check-test-coveragetarget (#1339). The Makefile's test targets used explicit pytest paths (tests/ python/tests/) which override pyproject.toml's testpaths, silently excludingpython/djust/tests/(2,734 tests across 100+ files). Added the missing directory to test-python, test-python-parallel, and the background test target. Newmake check-test-coveragetarget prevents recurrence by verifying every test directory is collected by CI. Verified bymake check-test-coverageand the 2,734 newly-collected existing tests. -
@reactive now fails at class-definition time on classes missing
update()(#1287). The@reactivedecorator previously guardedself.update()withhasattr(self, 'update'), silently no-opping when the host class lacked the method. It now uses__set_name__to validate at class-definition time, raisingTypeErrorwith a clear message. The_ReactivePropertydescriptor also callsupdate()automatically for both default and custom setters. 6 regression cases intest_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_statefor result tracking. 2 regression cases intest_background_return_value_docs.py. -
@computed memoized cache is now thread-safe (#1289). The
@computeddecorator's memoized form previously mutated the per-instance cache dict without synchronization, creating a race window between threads (e.g. a@backgroundcallback and template rendering). A per-instancethreading.Locknow protects the check-then-act cache mutation. 3 regression cases intest_decorator_computed_thread_safety.py. -
New
make check-handler-contractslinter (#1290).scripts/check-handler-contracts.pycross-references template-tag_eventemit 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 intest_check_handler_contracts.py. -
dj-form-pending now visible on WebSocket path (#1315).
sendEvent()was fire-and-forget — it returnedtruesynchronously, causinghandleEvent()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 aPromisethat resolves when the server's response (patch/noop/error with matching ref) arrives, via a new_pendingEventResolversMap alongside the existing pending-event tracking. All clear sites resolve pending resolvers on disconnect. 2 regression cases indj-form-pending.test.js(WebSocket path block). -
@server_function no longer hard-codes auth check (#1316).
dispatch_server_functionpreviously had an inline anonymous-user check that rejected all unauthenticated callers regardless of the view'slogin_requiredsetting. The check is removed —check_view_auth(view-levellogin_required/permission_required) andcheck_handler_permission(handler-level@permission_required) now govern auth, matching the ADR-008 contract.@server_functionno longer requires authentication by default. 4 regression cases intest_server_functions.py. -
#1281 regression tests moved to
python/tests/for CI coverage (#1325).test_skip_render_private_state.py(9 tests) was inpython/djust/tests/which is excluded from the explicit paths inmake test-pythonand the CI workflow. Moved topython/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 viak.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 usesview_instance._framework_attrsmembership (captured at__init__) to distinguish framework-internal_-prefixed attrs from user-defined private attrs set inmount()or event handlers. 9 regression cases intest_skip_render_private_state.py. -
_action_statenow persists across WebSocket reconnects (#1284). The@actiondecorator populates_action_state[action_name]with{pending, error, result}so templates can reference{{ action_name.error }}. Previously_action_statewas initialized before_framework_attrscapture in__init__, putting it in the framework-internal set that_snapshot_user_private_attrsand_restore_private_stateexclude. It now initializes after the capture, so the standard user-private save/restore cycle handles it automatically. 6 regression cases intest_action_state_reconnect.py. -
Snapshot truncation warning for large lists/dicts (#1285).
_snapshot_assigns()now emits a one-shotlogger.warningper 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: onlylen(v)instead of a key tuple), so in-place mutations inside them are not detected by auto-diff. The warning tells developers to useset_changed_keys()or assign a new reference. 10 regression cases intest_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 usedk.startswith("_")to filter attrs, while_snapshot_assigns()was updated in #1281 to use_framework_attrsmembership. 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 intest_change_detection_unified.py.
[0.9.2] - 2026-05-02
Fixed
markdownandnh3moved from optional extras to core dependencies.djust.components.templatetags.djust_componentseagerly imports both packages at Django template engine startup, making them hard requirements for any project with djust inINSTALLED_APPS. Previously they were only in the[components]extra, causingModuleNotFoundErrorwhen 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-transitionnow accepts 1-token short form, matching documented grammar. Closes #1273. Thedj-transition-groupdocs at43-dj-transition-group.js:22-23advertise short form likedj-transition-group="fade-in | fade-out"where each half can be a 1-token spec — but_parseSpecat41-dj-transition.js:45-60required 3 tokens, so the ENTER side was silently rejected and no animation fired. Fix: extend_parseSpecto accept 1-token form, return{single: <class>};_runTransitionhandles single by applying the class on next frame and waiting fortransitionend. 2-token form remains rejected as ambiguous (matchesdj-remove). 2 new regression cases inTestParseSpecAcceptsShortForm+ behavioral test (tests/js/dj_transition.test.js). -
AsyncResultnow serializes to a dict templates can navigate. Closes #1274.assign_async()returnsAsyncResultinstances that templates expect to read as{{ users.loading }},{{ users.ok }},{{ users.failed }},{{ users.result }},{{ users.error }}. Before this fix, neithernormalize_django_valuenorDjangoJSONEncoder.defaulthad anAsyncResultbranch — the value fell through tostr(), producing"AsyncResult(loading=True, ...)"which templates couldn't navigate. Result: everyassign_asyncdemo rendered blank. Fix: newAsyncResult.to_dict()method + register in both serializer paths;normalize_django_valuerecurses into the dict so a non-primitiveresultpayload (Django Model, datetime, Decimal nested in result) is normalized too. 11 regression cases inpython/djust/tests/test_async_result_serializer.py. -
Form submit now flushes pending debounced
dj-inputhandlers before dispatching. Closes #1278. Text/email/password inputs withdj-inputdefaulted 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 bydj-inputhandlers (e.g.,WizardMixin'swizard_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;_handleDjSubmitcalls it before reading FormData / dispatching. 4 regression cases intests/js/dj_submit_debounce_flush.test.js. -
dj-dialogclient-close (ESC/backdrop/dialog.close()) now syncs back to server. Closes #1267. Previouslydj-dialogwas 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-assertingdj-dialog="open"wasn't a value change. Fix: newdj-dialog-close-event="..."attribute opts into a nativecloseevent 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 intests/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_authon 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:69opens EventSource with{withCredentials: true};03b-sse.js:367sendMessagePOST setscredentials: 'include'. 2 new regression cases intests/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 frommount()never resolved over WebSocket — view stayed at initial loading-state HTML forever) and #1283 (push_event()called frommount()oron_mounthooks queued events that never reached the client). Both root at the same site:LiveViewConsumer.handle_mount()ended withsend_json(response)without draining_async_tasksor_pending_push_events. The fix mirrors the established pattern inhandle_event()/_flush_deferred_activity_events(): send the response frame, then drain push events, then dispatch async work. 3 regression cases inTestHandleMountSourceShape(python/djust/tests/test_handle_mount_drains_queues.py). -
data_tableintegration restored over WebSocket — emit defaults renamed to matchon_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-matchgetattr(view, event_name, None)(websocket_utils.py:173), but the tag-emit defaults previously used baretable_*strings while DataTableMixin useson_*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.pyclass-level attrs +_PRE_MOUNT_TABLE_CONTEXT,components/rust_handlers.py,templatetags/_forms.py); addon_table_prev/on_table_nexthandlers (clamped to[1, table_total_pages]); callrefresh_table()from sort/search/filter/page/prev/next handlers (selection handler deliberately exempt — UI state). 15 regression cases inTestDataTableEmitToHandlerCrossReference,TestPaginationHandlersExist,TestRowAffectingHandlersCallRefresh(python/djust/tests/test_data_table_handler_contracts.py). Subclasses that overrodetable_X_eventclass attrs are unaffected — only the bare-default path was broken. -
@actionno 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 recordederrorfield. Fix: catchException(notBaseException), record state, log at ERROR level vialogger.exception, return None.BaseExceptionsubclasses (KeyboardInterrupt,SystemExit,GeneratorExit) still propagate by Python convention. 8 new regression cases inTestActionExceptionDoesNotPropagate+TestActionSuccessRecordsStateTestActionLazyInitializesActionState(python/djust/tests/test_action_decorator_contract.py); 6 existing tests intest_action_decorator.pyupdated to the new contract. Docstring atdecorators.py:262-272rewritten to match. Behavior change: code that wraps@actioncalls intry/exceptto 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 inmount()(#1280, #1281). The decorator/tag audit catalogues 8 weaknesses including thedata_tabletag 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 forCLAUDE.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 usespush_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=Falseopt-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 (
-wsizing,--timeout 120,--keep-alive 5). Cloud-agnostic where possible; AWS as canonical example with PgBouncer / GCS / Cloudflare R2 noted in parallel.
- Channel Layer (cross-process push) — separate concern from
Developer Experience
- Pipeline-template canon: Stage 7 self-applicability check for canon
PRs (#1248). New optional checklist item in
.pipeline-templates/{feature,bugfix}-state.jsonStage 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 --statimmediately beforegit commitand verifies the staged line counts match the planned scope. Catches the failure mode wheregit add <file>silently bundles pre-existing uncommitted modifications (the pattern that hit pipeline-skill commitbf1a67f, 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.pywithRETRO_MARKER_REGEX. The audit script (scripts/audit-pipeline-bypass.py) now imports the canonical constant rather than embedding the literal. Stage 14subagent_prompttext 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 atscripts/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 commit18e5b117). The audit now also lists direct-to-main commits since the lookback window, filters out PR-squash commits via(#NNN)subject suffix, and honors anAudit-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 emittingMoveChildpatches 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 detectshas_unkeyed_siblingsupfront; in the mixed case, falls back to "always emit MoveChild whenold_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 incrates/djust_vdom/tests/test_mixed_keyed_unkeyed_reorder_1260.rs- permanent proptest seed in
fuzz_test.proptest-regressions.
- permanent proptest seed in
[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_htmlfordj-update="ignore"subtrees (#1252).splice_ignore_subtrees(crates/djust_vdom/src/lib.rs) used to copy the old node'scached_htmlinto 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_htmlrecomputes lazily on the next render. 4 regression tests incrates/djust_vdom/tests/test_ignore_subtree_invalidation_1252.rs. -
VDOM:
dj-idtemplate-injection defense-in-depth (#1253). The Rust parser now validates user-supplieddj-idattribute 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-levelparser_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 incrates/djust_vdom/tests/test_dj_id_validation_1253.rs. -
VDOM: duplicate
dj-keyand mixed-keyed-unkeyed warnings now fire attracing::warn!(#1254). Both warnings incrates/djust_vdom/src/diff.rspreviously usedvdom_trace!()(gated behindDJUST_VDOM_TRACE=1), so developers in production had no visibility into silent VDOM correctness issues. The mixed-keyed warning now fires with stable error codeDJE-050; the duplicate-key warning withDJE-051. The previously-citedhttps://djust.org/errors/DJE-050URL — which didn't exist — has been removed. Structured logging (key passed via{}placeholder) ensures the warnings are not log-injection vulnerable. 4 regression tests incrates/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 inpython/djust/static/djust/src/12-vdom-patch.jswas hardcoded toALLOWED_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 awindow.djustAllowedTagsruntime-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 existingALLOWED_HTML_TAGSwhitelist for legitimate use). 7 regression tests intests/js/vdom_web_components_1255.test.js. -
VDOM: extended SVG attribute camelCase normalization (#1256). The Rust parser's
normalize_svg_attribute()table incrates/djust_vdom/src/parser.rswas missing modern SVG attributes (filter primitives, animation timing, gradient transforms, font-face metrics). Browsers'setAttributeNSis case-sensitive; without normalization, the unknown camelCase attrs were silently ignored, producing visually-broken SVG. 11 new attrs added; 11 regression tests incrates/djust_vdom/tests/test_svg_attr_normalization_1256.rs- 10 new cases on the existing in-module test.
Added
- Transport-agnostic
ViewRuntimeshared between WebSocket and SSE (#1237). Newpython/djust/runtime.pymodule factors out view-lifecycle dispatch (dispatch_mount,dispatch_event,dispatch_url_change) so both transports share one code path for these message types. WebSocket'shandle_url_changeis now a thin shim overViewRuntime.dispatch_url_change; SSE's newPOST /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 withLiveViewWebSocket(#1237). ExistingliveViewWS.sendMessage(...)call sites in18-navigation.js,02-response-handler.js,13-lazy-hydration.js, and15-uploads.jsnow work transparently when the SSE transport is active — no callsite-by-callsite branching. The existingsendEventAPI is preserved (delegates tosendMessage). The legacyPOST /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 emptykwargsover SSE because_sse_mount_viewresolved againstrequest.path(the SSE endpoint URL/djust/sse/<uuid>/, not the page). The client now sends a WebSocket-shaped mount frame containingurl: 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; seedocs/sse-transport.md#why-not-the-referer-headerfor why. - SSE:
LiveView.handle_params()is now invoked after mount and onurl_change(#1237). Phoenix-parity contract:handle_params(params, uri)fires once aftermount()and on every subsequent URL change. Previously SSE never called it, causing views that read URL state inhandle_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()in18-navigation.jscallssendMessagefordj-patchURL updates; under SSE this previously crashed becauseLiveViewSSEhad nosendMessagemethod. 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.jspatchessendMessageto buffer payloads when the WebSocket is closed. With SSE now also exposingsendMessage, the patch was unconditionally applying — and becausews.wsis undefined onLiveViewSSE, every SSE payload was treated as "socket closed" and forwarded to the SW. The patch now short-circuits onLiveViewSSEinstances since SSE usesfetch()directly and doesn't need the WS reconnection buffer. ViewRuntime.dispatch_mountrejectsuse_actors=Trueviews with a structured error envelope over SSE (#1240). Closes plan-fidelity gap from #1237 — actor-based state management requires the channel-layer code inwebsocket.pywhich the runtime path doesn't traverse. Previously ause_actors=Truemount over SSE would partially succeed and fail downstream with an opaqueAttributeError. Nowdispatch_mountshort-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.ymlrunsscripts/audit-pipeline-bypass.pydaily 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). Manualworkflow_dispatchtrigger included for ad-hoc audits. - Isolated cargo-test target for
filter_registry::tests(#1235). The hot-path short-circuit tests for theANY_CUSTOM_FILTERS_REGISTEREDAtomicBool now live atcrates/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 previousOnceLockworkaround 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 waserror_type=. - Stage 7 WORKFLOW-HEADER CROSS-REF — when changed files include
.github/workflows/*.ymlor 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" butpipefailmade every flagged run red.
- 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
- Pipeline-run Stage 14 retro-post — Write tool +
gh --body-file(#1245). Updates.pipeline-templates/{feature,bugfix}-state.jsonStage 14 subagent_prompt to use Claude'sWritetool to createpr/feedback/retro-<N>.mdandgh pr comment <N> --body-file <path>to post — replacing the previouscat > file <<EOF+--body "$(cat file)"pattern that silently failed under zshset -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.ymlruns 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 therelease-workflow-reviewedlabel, 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. Therelease-workflow-reviewedlabel 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
variantkey that tints the row in the dropdown AND the trigger when that option is currently selected. Built-in variants align withBadge/Button/Tag/Alertvocabulary (info,success,warning,danger,muted,primary,secondary). Newvariant_mapkwarg mirrorsBadge.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/closeonclickhandlers 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 ondjust.testing.LiveViewTestClientthat wrapsview_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 strengthenedtest_normalize_idempotent_on_already_serializedregression test intests/unit/test_list_model_diff_1205.py, which now locks the #1206 normalize-pass idempotency contract via an explicitpatches == []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'sget_context_dataoverride setsctx["tasks"] = list(qs)after callingsuper().get_context_data(), the JIT auto-serialization pipeline runs insidesuper()and never sees the user-added value. The rawlist[Model]then flows through_sync_state_to_rust, where change-detection compares list elements via Python==— which delegates toModel.__eq__(pk-only). In-place field mutations (is_activetoggle,completedflip) don't changepk, 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: 0on every event,_debug.variables.tasks.valueshows only__str__strings.Initial fix (#1206):
_sync_state_to_rust(inpython/djust/mixins/rust_bridge.py) now runs a defensive normalize pass overfull_contextimmediately after fetching it, converting any homogeneouslist[Model]/Model/QuerySetvalue to dicts vianormalize_django_value. After normalization, change-detection compareslist[dict] != list[dict]element-wise viadict.__eq__(structural), correctly catching field mutations. Idempotent on already-serialized values. Also removed dead_lazy_serialize_contextmethod frompython/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_listchecked onlyvalue[0]) and nestedlist[list[Model]](grouped tasks). Refactored the inline normalize loop into a recursive_normalize_db_valueshelper that scans the full list for any-position Model and recurses into nested lists with bounded depth (_NORMALIZE_DEPTH_LIMIT = 3). 9 regression cases intests/unit/test_list_model_diff_1205.pylock down all shape variants: homogeneouslist[Model], singleModel, rawQuerySet, heterogeneous[dict, Model], nestedlist[list[Model]], idempotency, empty list, and mixed-type list. -
Broadcast renders now refresh
_recovery_html(#1202).server_pushinpython/djust/websocket.pywas sending broadcast patches without updatingself._recovery_html/self._recovery_version(the user-initiated event path did this; broadcasts were overlooked). Consequence: when a subsequentapplyPatcheson the client failed (the well-known{% if %}-shifts-DOM case), the client sentrequest_htmlexpecting fresh HTML, buthandle_request_htmlread a stale orNone-valued_recovery_htmland returnedrecoverable: 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_htmlvia the normal path. PR #1203 mirrors thehandle_eventrecovery-state-store atwebsocket.py:3271: before sending broadcast patches,server_pushnow stores the render output as_recovery_html/_recovery_version. Two regression cases intests/unit/test_server_push.pylock 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
.pxdexclude 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_eventwith optionaloverride_params) re-runs a recorded event; if the cursor is not at the buffer tip OR override params are present, the framework allocates a freshbranch_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
@actiondecorator exposes a pending/error/result triple viaself._action_state[name]to templates. No more boilerplateself.creating = True; try: ...; finally:. The dispatch pipeline catches errors and exposes them structurally. Mirrors React 19useActionStateshape 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 aStreamingMixin. 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 survivelive_redirectnavigation: WebSocket stays open, state preserved, in-flightstart_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-callsenable_hot_reload()wheneverDEBUG=Trueandwatchdogis installed. Existing per-consumer calls keep working unchanged. Opt-out viaLIVEVIEW_CONFIG['hot_reload_auto_enable']: False. Drop yourwatchfiles/--reloadwrappers — 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 viaasyncio.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=Trueusers onRustLiveViewno 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 renderrole="button",tabindex="0", respond to Enter/Space, short-circuit nested controls via capture-phase. Inlineonclickreplaced with external module soscript-src 'self'works out of the box. Defense-in-depth regex validatesdata-hrefagainstjavascript:/data:URIs. -
Time-travel wire-protocol additive fields.
time_travel_stateack frame andtime_travel_eventpush frame both carrybranch_idand 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 (fromdocs/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-optionvariantsupport andvariant_mapconvenience kwarg. Each option dict can carry avariantkey (info/success/warning/danger/muted/primary/secondary) that tints the dropdown row AND the trigger when selected. The variant vocabulary matchesBadge/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 onlocalhost:80xxdon't overwrite each other's theme preferences. -
Dev-deps include
markdownandnh3(#1149).[components]extra runtime deps are now also pulled in via[project.optional-dependencies.dev]souv sync --extra devbrings them in alongside the rest of the test toolchain.
Fixed
-
server_pushrecovery-state consistency (#1202). Push-driven sessions previously left_recovery_html/_recovery_versionunset, so a clientrequest_htmlafter a failed VDOM patch on a broadcast returnedrecoverable=falseand force-reloaded the page.server_pushnow mirrors thehandle_eventpattern of populating recovery state immediately before dispatching broadcast patches. -
Programmatic
RichSelectclass 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_varianthelper. -
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-timeTemplateSyntaxErrorto startup-time warning. Verbatim regions skipped; suppressible per-project. -
Async iterator drain in
arender_chunks(#1153). Unawaited_wait_for_onecoroutine warning fixed via explicit_drain_iteratorafter_cancel_pending. -
Test-runtime hygiene (#1186, #1152). Cross-runtime
dispatchEventwarnings (happy-dom + undici) and view-transitions teardown noise filtered via narrowonUnhandledErrorpatterns. Three consecutivemake testruns 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>), emptyexceptlogging inmixins/sticky.py, and a duplicateimport asyncioinmixins/request.py. -
CSP-nonce-aware activator for
<dj-lazy-slot>fills (#1147).{% live_render lazy=True %}propagatesrequest.csp_nonceonto 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_pushnow stores_recovery_html/_recovery_versionafter broadcast renders (#1202) — push-driven sessions previously left recovery state unset, so a clientrequest_htmlafter a failed VDOM patch (e.g.{% if %}shifting DOM structure on a broadcast) returnedrecoverable=falseand force-reloaded the page.server_pushnow mirrors thehandle_eventpattern of populating_recovery_html/_recovery_versionimmediately before dispatching the broadcast patches. Added 3 regression cases intests/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 fallbackwindow.location.href = nav.topath now validates the target is a same-origin absolute path. Rejects protocol-relative URLs (//evil.com), absolute URLs to other origins, andjavascript:/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: passinmixins/sticky.py:210now logs at DEBUG with a comment explaining the expected case (read-only proxy children that can't accept arequestattr). Closes CodeQL #2194. - Duplicate
import asyncioinmixins/request.py:322removed — 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_viewis a@classonlymethod;clsis correct. - 2× py/unused-global-variable (#2272, #2175) — both are referenced
multiple times (
_CUSTOM_FILTERS_BRIDGEDx4,_GCS_CHUNK_MIN_SIZEx3). - 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.
- 8× py/log-injection (#2254, #2253, #2239, #2238, #2237, #2236,
#2235, #2183) — log calls already pass user-controlled input
through
- JS open-redirect defense-in-depth (
[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-Nfrom forward-replay). Tracks the activebranch_idfrom 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_eventscap. - Forward-replay button (
⏵ replay) on every history row. Clicking sends aforward_replayframe withfrom_indexset to that row's index; the server allocates a newbranch_idif 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/↷ compbuttons that scrub a SINGLE component's state viatime_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_enabledis 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-togglerules), regenerated bundlesdebug-panel.js/.min.js/.min.js.gz/.min.js.brviascripts/build-client.sh. 23 new vitest cases intests/js/debug_panel_time_travel_ui.test.jscovering: 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 throughregisterTimeTravelClickHandlers). - Branch indicator at the top of the Time Travel tab — distinct
badge styling for
-
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_stateack frame: 3 new additive fields —branch_id(defaults"main"; new branches allocated asbranch-{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_eventper-event push frame: 2 new additive fields —branch_idand a top-levelcomponentsmirror ofentry.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 existingtime_travel_jumpvalidation and re-render path; backed by a newrestore_component_snapshot()helper inpython/djust/time_travel.py. - New handler
forward_replay: replays a recorded event with optionaloverride_paramsand allocates a fresh branch id when the cursor is not at the buffer tip. Backed by the existingreplay_event()(#1042) plus a newnext_branch_id()allocator. - Live-view init: 2 new instance fields on
LiveView.__init__—_time_travel_branch_id(default"main") and_time_travel_branch_counter(default0). 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, andwhich="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.mdClosing-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 HEADagainst the active state file'sbranch_namefield, catching the silent "wrong-branch commit" failure observed twice in v0.9.0.
- #1185:
Fixed
- v0.9.4 test-infra polish (closes #1188, closes #1189) — three
small follow-ups bundled as one PR:
- #1188 🟡 #1: narrowed
vitest.config.jsPattern 2 filter to match only the diagnosedClosing rpc+onUserConsoleLog/onConsoleLogcause from PR #1187. Dropped the broaderstack.includes('view-transitions')disjunct so future genuinely-different failure shapes inview-transitions.test.jscan no longer be silently swallowed. - #1188 🟡 #2: added
gc.collect()before the_wait_for_one-warning absence check intests/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_templatewall-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.
- #1188 🟡 #1: narrowed
Changed
- HVR auto-enabled in DEBUG (no AppConfig.ready() boilerplate
required) — djust's own
DjustConfig.ready()now auto-callsenable_hot_reload()wheneverDEBUG=Trueandwatchdogis installed. Existing per-consumerenable_hot_reload()calls keep working unchanged (idempotent viahot_reload_server.is_running()). Opt out viaLIVEVIEW_CONFIG['hot_reload_auto_enable']: Falsefor projects that orchestrate the file watcher externally. Test runs auto-skip viaPYTEST_CURRENT_TESTso pytest sessions don't spawn a watchdog thread per test. Files:python/djust/apps.py(auto-enable call appended toready()),python/djust/config.py(newhot_reload_auto_enable: Truedefault),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 filepython/djust/tests/test_auto_hot_reload.py). Drops the one-lineenable_hot_reload()call fromexamples/demo_project/demo_app/apps.py. Closes the friction observed across downstream consumers (docs.djust.org, djust.org, djustlive) that were either rolling their ownwatchfilesprocess-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 testbut never affected production behavior, all unblocking the canonical exit-0 gate:- #1186 (P1): happy-dom + undici WebSocket
dispatchEventcross-pollination — undici fires a Node-sideEventthat happy-dom'sEventTarget.dispatchEventruntime check rejects (the two runtimes don't share a Web-platformEventprototype). Filtered via a newonUnhandledErrorhook invitest.config.jsmatching a narrow message + stack pattern. Anything outside the pattern still re-throws. - #1152 (P2):
view-transitions.test.jsnon-deterministic teardownEnvironmentTeardownError: 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 sameonUnhandledErrorhook. - #1153 (P2): real lifecycle bug in
python/djust/mixins/template.pyarender_chunks, not warning suppression.task.cancel()only signals cancellation — it doesn't unblockdone.get()insideasyncio.as_completed's internal_wait_for_one. Whenarender_chunksreturned mid-loop onemitter.cancelled, the for-protocol's already-pulled coroutine plus any further iterator-yielded coroutines were GC'd unawaited and Python emittedRuntimeWarning: 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 testtest_cancel_does_not_leak_wait_for_one_warningintests/integration/test_chunks_overlap.pyasserts no_wait_for_onewarnings viawarnings.catch_warnings(1 new case). - Three consecutive
make testruns exit 0 post-fix (was non-deterministic 1-3 unhandled errors out of 1463 passing JS tests + 4047 passing Python tests).
- #1186 (P1): happy-dom + undici WebSocket
{% 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_SELECTORwas 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 throughwindow.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 forbindRow/initAllin #1170. - (c) Server-side contract test for URL allowlist (R5). New
tests/unit/test_data_table_url_allowlist_1171.pyparametrizes 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 indata-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.js14 → 17 (+3); newtest_data_table_url_allowlist_1171.py7 cases.
- (a) Nested-control selector — add
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_performancedocstring intests/unit/test_state_backend.pyto match what the 100ms bound actually catches (catastrophic ~10× regressions, e.g. accidental JSON/pickle round-trip), not gradual perf drift. Points topytest-benchmark-style median-based assertions for SLA-grade perf checks. - #1165 (a): extend
TestReplayHandlerValidationrejection-path tests intests/unit/test_time_travel.pyto assert viacaplogthat thelogger.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
LiveComponentdocstring (python/djust/components/base.py) and indocs/website/guides/components.md. The framework's_assign_component_idswalker only inspects instance-level attrs, so descriptor components must be appended toself._componentsinmount()until auto-promotion ships. Time-travel snapshots and other walkers silently miss them otherwise. - #1165 (c): add
scripts/check-dev-env-imports.pyand a paired pytest module (tests/unit/test_dev_env_imports.py, 2 new parametrized cases) that hard-fail (not skip) ifdjust.components.componentsor its.markdownsubmodule cannot import. Locks in the #1149 fix where missingmarkdown/nh3caused opaque pytest collection failures. Script is standalone for now; a follow-up PR can wire it into pre-commit / Makefile.
- #1160: rewrite
- CSP-strict defaults canonicalized for new client-side framework code
(closes #1175) — adds explicit guidance in
CLAUDE.md,docs/PULL_REQUEST_CHECKLIST.md, anddocs/guides/security.mdthat any new framework feature emitting HTML must default to: external static JS modules (no inline<script>blocks), no inline event handlers (noonclick=/onchange=/oninput=), auto-bind via marker class + delegated listener ondocument/root, CSP nonce propagation only when genuinely required (lazy-fill case from #1147 is the canonical exception). Reference-module shapes documented (PR #1170data-table-row-click.js, PR #113850-lazy-fill.js, existing39-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 shippedrow_click_event/row_urltemplate-tag args, mixin defaults, and structural wiring). What's added:- Accessibility: every row-clickable
<tr>now rendersrole="button",tabindex="0", andcursor: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 === trso 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-phasestopImmediatePropagation, so the row-level action never fires for those clicks. This is the integration point withselectable=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 underscript-src 'self'out of the box. - Defense-in-depth:
data-hrefvalues are regex-validated against/^(https?:|\/|\.)/beforewindow.location.assign, so a hostilejavascript: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 intests/js/data_table_row_click.test.jscover: role + tabindex presence, marker class on/off, no-inline- onclick (CSP), checkbox cell composition, click navigation, nested<a>/<input>guard, Enter/Space activation,activeElementguard, javascript: URI rejection, dj-click composition (capture-phase stop), and bindRow idempotence. One pre-existing structural test inpython/tests/test_data_table_link_row_nav.pywas rewritten to assert the newdata-table-row-clickablemarker class instead of the removed inlineonclick. - Accessibility: every row-clickable
-
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 onlocalhost:80xx(or any shared domain) don't overwrite each other's theme preferences. Browsers scope cookies by domain only — not by port — so the fourdjust_theme*cookies bleed across projects without this. PR #1013 already shippedenable_client_override: Falseas a workaround, but that breaks sites with a user-facing theme switcher; this is the missing piece for those sites. Whencookie_namespace="djust_org"is set, the cookies becomedjust_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) readswindow.__djust_theme_cookie_prefixinjected bytheme_head.htmland writes only the namespaced name when set. When unset (default), the legacy unprefixed names are used — existing deployments unaffected. 8 new regression cases intests/unit/test_theming_cookie_namespace_1158.pycover namespaced precedence, unprefixed fallback, default back-compat, two-namespace isolation, all four cookies honour the namespace, and thetheme_head.html+theme.jswrite-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 onRustLiveViewgot a "no handler registered for tag: live_render" template error if they usedlazy=True, forcing a fallback to the slower Django engine to use streaming. The Rust handler delegates to the existing Python implementation indjust.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 onparent._lazy_thunks, same CSP nonce propagation, samesticky=True + lazy=Truecollision raise. The bridge required threading the raw Python sidecar (request,view) through to the custom-tag handler context:crates/djust_coreexposesContext::raw_py_objects()for read access, andcrates/djust_templates::registryaddscall_handler_with_py_sidecar(a backward-compatible variant ofcall_handler— existing handlers ignore the extra Python objects in their dict). 8 parity regression cases intests/unit/test_rust_live_render_lazy_1145.pycover lazy=True placeholder byte equivalence, lazy="visible" parity, thunk stash on the Rust path, CSP nonce parity, sticky+lazy collision, the inline-attributetemplate = "..."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-timeTemplateSyntaxErrorto a startup-time warning so the misuse surfaces duringmanage.py checkinstead 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 canreplaceWiththe stashed subtree;lazy=Truedefers 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_blockshelper from the v0.7.3 #1004 fix). Silenceable per-project viaDJUST_CONFIG = {"suppress_checks": ["A075"]}. 8 regression cases inTestA075StickyLazyCollisioncover 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 propagatesrequest.csp_nonce(the Django convention set bydjango-cspmiddleware) onto BOTH the<template id="djl-fill-X">element AND the inline<script>activator that callswindow.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 readsgetattr(request, 'csp_nonce', None)via the existingdjust.utils.get_csp_noncehelper — no additional configuration is required for any CSP middleware that follows the Django convention. Whenrequest.csp_nonceis absent or empty (the common case for sites without CSP middleware), nononceattribute is emitted — backward-compatible for non-CSP deployments. The placeholder<dj-lazy-slot>also carries the nonce so client-side code can read it viagetAttribute('nonce')if it ever needs to inject CSP-bound scripts under the same policy. 6 Python regression cases intests/unit/test_lazy_render_csp.py- 3 JS cases in
tests/js/lazy_fill_csp.test.jscover nonce propagation, backward compatibility (no nonce attr whencsp_nonceis absent / empty / missing), and HTML-escaping defense-in-depth for hostile-middleware substitutes.
- 3 JS cases in
Changed
- Dev-deps include
markdownandnh3(closes #1149) — both packages are runtime deps of the[components]extra (seepython/djust/components/components/markdown.py) and the components subpackage's__init__.pyeagerly imports them viafrom .markdown import Markdown. Tests that importdjust.components.components(directly or transitively) failed collection in clean checkouts that ran onlyuv syncwithout the[components]extra. The bisect agent in PR #1159 hit this on a fresh clone. Added both to[project.optional-dependencies.dev]so a singleuv sync --extra devbrings 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 distinguishesNone(cookie not in jar) from""(cookie set to empty), and only falls back in the former case. - (b)
cookie_namespacevalidated 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 raisesImproperlyConfiguredat 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.jssource-text patterns; newtests/js/theming_cookie_namespace_write.test.jsloads the file in JSDOM, setswindow.__djust_theme_cookie_prefix, firessetPack/setPreset/setLayout, and inspectsdocument.cookie. - (d) Legacy-cookie cleanup on first namespaced write. When
cookie_namespaceis set, every theming-cookie write intheme.jsnow also emitsMax-Age=0for 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 intests/js/theming_cookie_namespace_write.test.js(4 for sub-item (c), 3 for sub-item (d)). - (a) Empty namespaced cookie no longer falls back to legacy.
-
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.pypreviously used per-classsetup_registryfixtures that re-registered the Python built-in handlers on teardown but did NOT clear the global RustTAG_HANDLERSregistry first. Transient handlers from the file (notablyBrokenHandlerregistered for thebrokentag intest_handler_exception_returns_error) leaked into subsequent test files.test_assign_tag.pyrunning after this file would seehandler_exists("broken")== True; the parser dispatcheshandler_existsbeforeassign_handler_existsso{% broken %}was routed to the leaked CustomTag handler andtest_non_dict_return_is_empty_mergefailed 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 fromdjust.template_tags._registered_handlers. The file is now self-contained. - Sidecar parity: PR #1166's
call_handler_with_py_sidecaronly fired forNode::CustomTag. Block tags (Node::BlockCustomTag) and assign tags (Node::AssignTag) didn't receive therequest/viewsidecar, so a custom block or assign handler couldn't reach the parent view. Addedcall_block_handler_with_py_sidecarandcall_assign_handler_with_py_sidecarmirroring the PR #1166 pattern; the existing variants are kept as back-compat shims that delegate withNone. All five renderer call sites (1× block, 4× assign — single-node, sibling-aware, collecting, and partial-render paths) forwardcontext.raw_py_objects().
New cases in
TestBlockTagSidecarandTestAssignTagSidecar(tests/unit/test_tag_sidecar_parity_1167.py, 6 Python cases) cover sidecar receipt ofrequestandviewper node type plus a back-compat regression per node type confirming legacy handlers that ignore the sidecar continue to work unchanged. - Test isolation:
-
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:
- Hot-path Mutex perf:
is_custom_filter_safeandapply_custom_filtershort-circuit on a newANY_CUSTOM_FILTERS_REGISTEREDAtomicBoolso projects with no custom filters pay only an atomic load on every variable expansion'sfilter_specs.iter().any(...)loop, never a Mutex acquire. Acquire/Release ordering pairs the load with the store inregister_custom_filter. - Hardcoded
autoescape=Trueplumbing (correction, #1180):apply_custom_filternow accepts anautoescape: boolparameter that's set as a kwarg on the Python callable when the filter declaresneeds_autoescape=True. The earlier wording here was inaccurate — onlyapply_custom_filterwas widened; the upstream chain (apply_filter_fullinfilters.rsand the renderer's three call sites atrenderer.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. - Unknown-filter test tightened: assert
RuntimeErrortype AND the canonical"Unknown filter:"message shape, not justpytest.raises(Exception)+ substring on filter name only. - 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. - Fixture isolation comment: the
scope="module"autouse fixture intests/unit/test_rust_custom_filters_1121.pynow carries an explicit comment that this file is not safe to run in parallel with other Rust-filter-registry-touching tests. - Silent async filter handling: an
async defcustom filter previously stringified the unawaited coroutine ("<coroutine object ...>") into the rendered HTML with a "coroutine was never awaited" RuntimeWarning at GC. Now usesinspect.iscoroutineto detect and reject with a clear, actionable error andcoro.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) andautoescapekwarg flow (sub-item 2). Two new Rust unit tests infilter_registry::testscover theAtomicBoolshort-circuit pre-registration. - Hot-path Mutex perf:
-
replay_eventvalidates 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/privateevent_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 callsdjust.decorators.is_event_handler(handler)after attribute resolution, mirroring the dispatcher's own acceptance criteria (seewebsocket.py~ line 4389 server_push handler validation). Unregistered methods log a warning and returnNoneinstead of invoking. 3 regression cases inTestReplayHandlerValidationcover 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.filterin theirtemplatetags/modules saw them work in the Python render path but fail under the RustRustLiveViewrender path withRuntimeError: 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.rsholds a process-wideMutex<HashMap<String, FilterEntry>>of project filter callables + per-filter metadata (is_safe,needs_autoescape). - The renderer's filter loop forwards an
arg_was_quotedhint 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_safeandfilter.needs_autoescapefrom the Django filter object are honoured:is_safe=Truefilters skip auto-escape;needs_autoescape=Truefilters receiveautoescape=Trueas a kwarg. python/djust/template_filters.pywalkstemplate.engines['django'].engine.template_librariesat 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 inTestRustCustomFilterscover the lookup shape from the issue body,is_safe,needs_autoescape, quoted vs context- resolved args, plain-text auto-escape, and the fullRustLiveViewrender path.
- New
-
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/ChunkEmitterasync-render path and after PR #998 added theblock_watchdogtest 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 duringcommunicator.disconnect()because Channels' consumer dispatch invokesaclose_old_connections(), which iterates Django's connection cache and callsclose_if_unusable_or_obsolete()→get_autocommit()→ensure_connection(). SQLite ignoresclose()for in-memory DBs (data-loss prevention), so a prior django_db-marked test leaves the connection wrapper with.connection != Nonein the thread-local; pytest-django's blocker then fires inside the consumer's cleanup. Marked the affected tests@pytest.mark.django_dbso they participate in pytest-django's connection management. sys.modules["djust.checks"]rebind: thetest_dev_server_watchdog_missing.py::test_check_hot_view_replacement_survives_without_watchdogtest deleteddjust.checksfromsys.modulesand re-imported, creating a new module object whiletest_static_security_checks.pyhad already donefrom djust.checks import check_configurationat collection time. Subsequentmock.patch("djust.checks._has_multiple_permission_groups", ...)targeted the new module while the oldcheck_configurationkept resolving names against the old module's__dict__— so the patch silently no-op'd andtest_a020_fires_with_multiple_groupsfailed. Moved snapshot/restore ofdjust.checksanddjust.dev_serverinto theblock_watchdogfixture'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.
- In-memory SQLite + Channels disconnect: 5 tests
(
[0.9.0rc2] - 2026-04-27
Changed
-
WizardMixin.as_live_fieldauto-picksdom_eventby widget class (closes #1156) — previously the view-levelwizard_input_eventattribute applied uniformly to every widget the wizard rendered. An author settingwizard_input_event = "dj-input"to capture unblurred text edits (per #1095) unintentionally also stampeddj-inputon 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_fieldnow 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-changefor 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_eventfor 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_fieldoverride to do exactly this mapping can delete the override.New
_CLICK_FIRED_WIDGET_CLASSESClassVar (frozenset of widget class names) lets apps with custom commit-style widgets extend the dispatch without overridingas_live_fielditself:class MyWizard(WizardMixin, LiveView): _CLICK_FIRED_WIDGET_CLASSES = frozenset({ *WizardMixin._CLICK_FIRED_WIDGET_CLASSES, "MyColorPickerWidget", })
Files:
python/djust/wizard.py(new_default_dom_event_forhelper +_CLICK_FIRED_WIDGET_CLASSESClassVar, ~20 LoC; updatedwizard_input_eventdocstring to clarify text-only scope). 15 new cases inTestAsLiveFieldWidgetAwareDomEventintests/unit/test_wizard_mixin.pycover text/textarea/integer/email trackingwizard_input_event, radio/select/checkbox/ CheckboxSelectMultiple locked todj-change, caller-passeddom_eventoverrides, ClassVar extension for custom widgets, and MRO walk catchingSelectMultiple/NullBooleanSelect/ app-defined RadioSelect subclasses.
Fixed
-
dj-inputon click-fired widgets no longer incurs a 300ms debounce (closes #1154) —DEFAULT_RATE_LIMITSinpython/djust/static/djust/src/08-event-parsing.jswas missing entries forradio,checkbox,select-one, andselect-multiple. The input handler's fallback ({ type: 'debounce', ms: 300 }) kicked in for these widget types, soWizardMixin.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
passthroughrate-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 in09-event-binding.jsthat skips the rate-limit wrapper whenrateLimit.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 send total click → DOM Before 1104 ms ~1150 ms After 1 ms ~75 ms dj-debounce/dj-throttleexplicit overrides on a radio still work — passthrough is the default, not a mandate. Files:python/djust/static/djust/src/08-event-parsing.js(4-lineDEFAULT_RATE_LIMITSextension),python/djust/static/djust/src/09-event-binding.js(7-linepassthroughbranch + a one-lineObject.assign({}, …)clone of the default before the override branches mutate it — without the clone,dj-debounce/dj-throttleon one element permanently flips the sharedDEFAULT_RATE_LIMITSentry and pollutes every subsequently- bound element of the same type). 9 new cases intests/js/dj-input-click-widgets.test.jslock in synchronous firing for radio/checkbox/select-one/select-multiple, continued debounce for text/textarea, thatdj-debounceoverrides 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 itsstate_beforebaseline either deterministically (originalparams) or with caller-suppliedoverride_paramsto fork a branched timeline.Builds on #1041's per-component capture: replay restores via
restore_snapshot(view, snap, "before")which dispatches toview._components[id]instances. So a handler that readsself._components[id].valueduring replay sees the CAPTURED value, not the live one. The testtest_replay_restores_component_state_before_invokinglocks 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=Falseruns a "dry" replay — view is mutated for preview, buffer is unchanged.Handler-missing path: returns
Noneand logs a warning (handler was renamed since the snapshot was captured). Handler-raises path: the new snapshot'serrorfield 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_eventfunction +__all__extension). 7 new cases inTestReplayEventintests/unit/test_time_travel.pycover 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=TrueAPI → 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_stateadds a reserved__components__key holding a{component_id: {field: value}}nested dict. Components inself._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_snapshotdetects__components__in the snapshot and dispatches each{component_id: state}entry to the matching component inview._componentsviasafe_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_snapshothelper +_capture_snapshot_stateextension);python/djust/time_travel.py(~40 LoC:_COMPONENTS_SNAPSHOT_KEYconstant + per-component restoration phase). 7 new cases inTestComponentLevelTimeTravelintests/unit/test_time_travel.pycover 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 inarender_chunksPhase 5 (one thunk runs to completion before the next starts; total wall-clock time = sum of thunk durations). PR-C swaps the for-loop forasyncio.as_completedover 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-targeton<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 throughsync_to_asyncto 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 toasyncio.as_completed). 3 new wall-clock-sensitive tests intests/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=Trueuser API +as_viewdispatch) → PR-C (parallel render). -
{% live_render lazy=True %}capability +as_viewdispatch 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,placeholderkeys).At template-render time the tag emits a
<dj-lazy-slot data-id="X" data-trigger="flush">placeholder synchronously and registers a thunk onparent._lazy_thunks.RequestMixin.agettransfers the stash onto theChunkEmitterafter the sync render completes. Phase-5 ofarender_chunksinvokes 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.jsmodule'swindow.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 =
TemplateSyntaxErrorat tag eval — hard incompatibility per ADR §"Failure modes". Sticky preservation requires the slot to exist at mount-frame time so the WS reattach canreplaceWiththe stashed subtree; lazy renders the slot AFTER mount, so the stash-target doesn't exist when reattach runs.as_view()dispatch wiring —LiveView.as_viewis now overridden so that classes withstreaming_render = Truereturn an async view callable (viamarkcoroutinefunction) that routes GET toaget()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 syncdispatchviasync_to_async, preserving the Phase-1 cosmetic chunked response behavior. The ASGI/WSGI signal isisinstance(request, ASGIRequest)— accurate even when the sync testClientwraps the async view viaasync_to_sync(the earlier loop-presence check was fooled by that wrapping).Files:
python/djust/templatetags/live_tags.py(~210 LoClazy=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_thunksreset + ASGIRequest-aware_is_asgi_context),python/djust/live_view.py(~50 LoCas_viewoverride). New:python/djust/static/djust/src/50-lazy-fill.js(~140 LoC client). 14 new cases intests/unit/test_live_render_lazy.pycover validation, placeholder emit, thunk stash, thunk closure including error + timeout envelopes. 2 new integration cases intests/integration/test_lazy_streaming_flow.pydrive 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_completedparallel 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 sostreaming_render = Trueshell-flushes to the wire BEFOREget_context_data()runs.New module
python/djust/http_streaming.py(~230 LoC) provides theChunkEmitterclass — a per-request boundedasyncio.Queuewith backpressure, cancellation propagation viarequest_token, and aregister_thunk()API surface that PR-B ({% live_render lazy=True %}) will hook into. The emitter exposes__aiter__for direct consumption byStreamingHttpResponse.New
async def aget()onRequestMixin(~150 LoC) parallel to the existing syncget(). Wraps the sync render viasync_to_async(self.get)to produce the full HTML, then drivesarender_chunks()to push chunks through the emitter. Returns aStreamingHttpResponsewithX-Djust-Streaming: 1andX-Djust-Streaming-Phase: 2headers. ASGI disconnect watcher cancels the emitter when the client closes the connection.New
arender_chunks()async coroutine onTemplateMixin(~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 viaemitter.emit()withawait asyncio.sleep(0)boundaries so ASGI flushes the shell to the wire before the body chunks are queued. Cooperative cancellation viaChunkEmitterCancelled. Single-chunk fallback for fragment templates (no<div dj-root>).streaming_render = False(default) stays on the syncHttpResponsepath. WSGI deployments fall back to the Phase-1 regex-split-after-render via_make_streaming_responseper 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 intests/unit/test_async_render_path.pycover ChunkEmitter basics + backpressure + cancellation,arender_chunks4-yield invariant + fragment fallback + mid-stream cancel,agetstreaming response shape + redirect passthrough + non-streaming fallback, and_get_queue_max_from_settingsdefaulting.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_preservedregistry at render time. When a survivor exists for the resolvedsticky_id, the tag re-registers the survivor onto the new parent, marks the id in a newconsumer._sticky_auto_reattachedset, and emits a<dj-sticky-slot>placeholder rather than a fresh subtree. The consumer's existing slot scan + the client's existingreplaceWithreattach then complete the round-trip without ever callingmount()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_consumerback-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_reattachedset init/reset + slot-scan skip-on-claim, ~12 LoC),docs/adr/014-sticky-liveview-autodetect.md(new ADR). 4 new cases inTestStickyAutoDetectintests/unit/test_live_render_tag.pycover no-consumer, empty-preserved, preserved-for-our-id, and preserved-for-other-id paths. 2 new integration cases intests/integration/test_sticky_redirect_flow.pydrive 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_FakeConsumerrig.
- consumer slot-scan skip-on-claim + survivor in
[0.8.7rc1] - 2026-04-26
Fixed
-
DataTableMixin.get_table_context()post-mount missingshow_statskey (closes #1118) — Stage 11 review of PR #1117 surfaced thatshow_statswas present in_PRE_MOUNT_TABLE_CONTEXT(the empty-table default returned beforeinit_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 raiseVariableDoesNotExistpost-mount onceinit_table_state()had populated real state. One-line fix adds"show_stats": self.table_show_statsto the post-mount dict, alongside the existingprintable/column_statskeys.Files:
python/djust/components/mixins/data_table.py(one-key addition inget_table_context()); 2 new cases inPreMountGuardTestinpython/tests/test_data_table_mixin_liveview.pycover post-mount default-False and class-override-True paths. The pre-existingtest_pre_mount_default_has_required_template_keyssymmetry 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-stringself-review checkpoint — useconsole.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 Nonecoalesce vskwargs.setdefaultfor 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]overlist[T]for membership-check parameters (#1108); dynamic subclass viatype(name, bases, dict)over class-attr mutation in test fixtures (#1109); microtask-faithful test stubs forstartViewTransition/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.C013system check — stale collectstatic copy ofclient.min.js(closes #1088) — anyone withSTATIC_ROOTconfigured (typical production deployment behind WhiteNoise / nginx / a CDN) can ship a staleclient.min.jsafter a djust wheel upgrade if they forgetcollectstatic --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.jsagainst the wheel-bundled copy atpython/djust/static/djust/client.min.js. When they diverge, emits a Django system warning at startup with the exact fix command. No-op whenSTATIC_ROOTis unset, when the collected file is absent (pre-collectstatic), or when content matches. HonorsDJUST_CONFIG = {"suppress_checks": ["C013"]}for users who serveclient.min.jsfrom a CDN or custom build.Files:
python/djust/checks.py(new_check_stale_collected_client, wired intocheck_configuration); 5 cases inTestC013StaleCollectstaticinpython/tests/test_checks.pycover no-STATIC_ROOT skip, no-collected-file skip, matching-content quiet, diverged-content warning, suppress-via-DJUST_CONFIG silence.
Fixed
-
|dateand|timefilters 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 viatracing::debug!against targetdjust.templates.filterswith the offending value, format string, and chrono error message.Enable via Python
LOGGING['loggers']['djust.templates.filters'] = {'level': 'DEBUG'}or setRUST_LOG=djust.templates.filters=debugfor the Rust-sidetracingconsumer. Behavior unchanged when the log target is disabled — just no longer a silent void.Files:
crates/djust_templates/Cargo.toml(addedtracingworkspace dep),crates/djust_templates/src/filters.rs(|datearm at line ~248,|timearm at line ~284 — replacedErr(_) => Ok(value.clone())withErr(e) => { tracing::debug!(...); Ok(value.clone()) }). -
_flush_deferred_to_sselegacy-view guard now has a regression test (closes #1093) — Stage 13 review of PR #1091 flagged that the WS-sidehasattrguard had a parallel test (test_flush_deferred_handles_view_without_drain_method) but the SSE-side did not. Newtest_sse_flush_deferred_handles_view_without_drain_methodinpython/djust/tests/test_defer.pymirrors the WS shape — a legacy view class without_drain_deferredmust short-circuit cleanly withoutAttributeError. -
Release wheel matrix expanded to cp313 + cp314 (closes #1089) —
.github/workflows/release.ymlpreviously built only cp310/cp311/cp312 wheels. Users on Python 3.13 or 3.14 fell back to source-compiling the sdist atpip installtime, 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 supportsdocument.startViewTransition()AND the body attribute is present AND the user has not requestedprefers-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-nameCSS — 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 at02-response-handler.js:109fires. 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-prefetchwork 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.jsadds_shouldUseViewTransition()gate and refactorsapplyPatchesinto a thin wrap-or-direct dispatcher; the existing patch-loop body becomes_applyPatchesInner(sync — no behavior change inside). Cleanup:03-websocket.js(2 sites) and03b-sse.js(1 site) drop the now-redundant outer.catch()onhandleMessagecalls — 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.jscovers all four_shouldUseViewTransitionbranches (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 viaawait 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-hooklifecycle dispatch (v0.8.6 enhancement cashing in PR-A async refactor) —dj-hooklifecycle methods (mounted,updated,beforeUpdate,destroyed,disconnected,reconnected,handleEvent) may now beasync. The dispatcher detects Promise return and chains.catchto log rejections viaconsole.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 inpython/djust/static/djust/src/19-hooks.jswraps 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 filetests/js/async_hooks.test.jswith 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-motionaccessibility bypass, shared-element transitions viaview-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.yamlandindex.mdper the docs-nav convention. -
{% data_table %}link column type (closes #1110) — column dicts now accept alinkkey naming another row dict key that holds the href, and an optionallink_classfor 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.linkis unset — strict backwards-compat with pre-#1110 column dicts. Replaces the_inject_link_columnregex 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_eventfires a djust event withdata-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_urlnames a row dict key containing the href; the<tr>getsdata-href+ anonclickthat readsthis.dataset.hrefand navigates:table_row_url = "claim_url"
Both options also wire
style="cursor:pointer"on each<tr>for the affordance.row_click_eventtakes precedence when both are set. Mirrored inDataTableMixinviatable_row_click_event,table_row_click_value_key, andtable_row_urlclass attributes, threaded throughget_table_context()and_PRE_MOUNT_TABLE_CONTEXT.Security note for Option A (
row_url): the URL flows into JS viaonclick="window.location=this.dataset.href". Only assign developer-controlled URLs (typically computed fromreverse()); user-controlled strings could enablejavascript:URI execution. CSP note: Option A requires'unsafe-inline'inscript-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.pycover: link-column emits<a>; link_class flows through; no-link pre-#1110 compat;row_click_eventaddsdj-clickto every<tr>;row_click_value_keyoverrides defaultid; absentrow_click_event→ no<tr>dj-click(compat);row_urladdsdata-href+ JS;row_click_eventprecedence overrow_url; mixin class-attr defaults; per-view override; pre-mount default + post-mount context- template-tag function include all 3 new keys.
Fixed
-
DataTableMixinLiveView compatibility — pre-mount guard +@event_handler()decoration on allon_table_*methods (closes #1114) — usingDataTableMixinin aLiveView(rather than aComponent) caused a blank/empty table on every page load even whenrefresh_table_server()correctly populatedself.table_rowsinmount(). Three compounding root causes:- BUG-06 pre-mount lifecycle: djust's WebSocket consumer calls
get_context_data()(which often callsget_table_context()) BEFOREmount()runs, to build the initial Rust VDOM snapshot.init_table_state()hadn't run yet, soself.table_rowsdidn't exist andget_table_context()raisedAttributeError. djust caught it silently → empty initial VDOM → all subsequent VDOM patches diff against empty content → wrong renders. - Missing
@event_handler()decoration:on_table_sort,on_table_search, and 19 other handlers were plain methods. djust's defaultevent_security="strict"rejected them — every consumer had to write wrapper boilerplate. - 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 onhasattr(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 21on_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 viaget_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,
DataTableMixinis usable fromLiveViewsubclasses without per-handler boilerplate.8 regression cases in
python/tests/test_data_table_mixin_liveview.pycover: 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_decoratorsmetadata; handler count matches expected (catches future additions that forgot decoration); docstring mentions LiveView lifecycle and@event_handler()decoration (catches doc-rot). - BUG-06 pre-mount lifecycle: djust's WebSocket consumer calls
-
handleMessageinterleaving acrossawaitboundaries (closes #1098) — PR-A (v0.8.5rc1) madeLiveViewWebSocket.handleMessageandLiveViewSSE.handleMessageasync without serializing the inbound frame queue. Two adjacent inbound frames could fire-and-forget_handleMessageImplconcurrently and interleave theirawait handleServerResponsecalls — racing on shared state like_pendingEventRefs/_tickBuffer(03-websocket.js:561-568reads.sizeAFTER anawait, 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 insideapplyPatchesitself.Fix: per-transport
_inflightPromise chain. EachhandleMessage(data)invocation chains onto the prior in-flight promise. Sequential drain across rapid-fire frames; no interleaving. Errors propagate through.catch()(logged viaconsole.error) so the chain continues even when one frame rejects — a single bad frame doesn't poison the queue.Existing async
handleMessagebody renamed to_handleMessageImpl(private). New publichandleMessage(data)is a thin wrapper that enqueues ontothis._inflight. Both transports (WebSocket + SSE) apply the same pattern.New regression file
tests/js/handlemessage_serialization.test.jscovers: 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 exposehandleMessageand_handleMessageImplseparately and serialize.Caller-side test migration: 4 existing test files updated to
awaitthe now-queuedhandleMessagecalls (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_fieldsopt-in skipsfield_htmlrendering for fields not in the list (closes #1097) —WizardMixin.get_context_data()unconditionally pre-renderedfield_htmlfor 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 behindis_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 —
Nonerenders all):- Class-level:
wizard_rendered_fields = ["first_name", "vin", ...]on the wizard view limitsfield_htmlto 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, andform_choicesare NOT filtered — all fields remain part of validation/state. Only the (expensive) HTML rendering is opt-in skipped. Excluded field names produce nofield_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 inget_context_data());python/tests/test_wizard_rendered_fields.pywith 8 cases inDefaultRendersAllFieldsTest,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.
- Class-level:
-
WizardMixin.wizard_input_eventclass attribute +dom_eventkwarg onas_live_field()— configurable DOM event for live-field validation binding (closes #1095) —WizardMixin.as_live_field()previously emitteddj-change="<handler>"unconditionally on text/textarea/select/checkbox/ radio inputs.dj-changefires 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-inputfires on every keystroke (300ms client-side debounce already in09-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_fieldforwardsdom_eventthroughkwargs.setdefault),python/djust/ frameworks.py(5 sites —_render_inputtext/textarea/select,_render_checkbox,_render_radio— readkwargs.get("dom_event", "dj-change")instead of hardcoding"dj-change").14 regression cases in
python/tests/test_wizard_input_event.pycover: default class attribute is"dj-change"; default rendering on text/textarea/select/checkbox/radio emitsdj-change; per-calldom_event="dj-input"swaps todj-inputand removesdj-change;wizard_input_event = "dj-input"flows throughas_live_field(); per-call kwarg overrides class attribute;dom_event=Nonecoalesces to the class attr instead of producingattrs[None]. -
self.defer(callback, *args, **kwargs)— Phoenix-style post-render callback scheduling — new method onAsyncWorkMixin(and therefore on everyLiveView) that schedules a callback to run once, after the current render+patch cycle completes. Phoenixsend(self(), :foo)/ ReactuseEffect(post-render) parity. Fires synchronously in the same WebSocket message cycle (after_send_updatereturns) — 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:deferdoes NOT trigger a re-render after the callback returns (the caller would usestart_asyncfor that), and runs synchronously in the same WS frame rather than spawning a background thread. Append-only queue: everydefer()call adds to a per-view list that is drained and cleared byLiveViewConsumer._flush_deferred()after every_send_update()call (10 sites inpython/djust/websocket.py, mirroring the existing_flush_push_events/_flush_flash/_flush_page_metadata/_flush_pending_layoutpost-render-flush pattern).Async callbacks (
async defor 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 inpython/djust/tests/test_defer.pycover queue mechanics (append/drain/clear), arg/kwarg passing, ordering, sync+async mix, exception isolation, edge cases (noview_instance, view withoutAsyncWorkMixin), drain-reentry contract (a callback that callsdefer(other)enqueuesotherfor the next drain — Phoenix-style, prevents unbounded loops), and SSE transport integration (mirror flush via_flush_deferred_to_sse()inpython/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 inROADMAP.md.
Changed
-
VDOM
applyPatchessignature is nowasync(returnsPromise<boolean>) — foundational refactor preparing for View Transitions API integration (ADR-013). PreviouslyapplyPatches(patches, rootEl)returnedbooleansynchronously; nowasync 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. EachawaitsapplyPatchesand propagates async upward —handleServerResponseis nowasync,LiveViewWebSocket.handleMessageandLiveViewSSE.handleMessageare nowasync, and theEventSourceonmessagearrow callbacks (which cannot beasyncin their declared form) wrap theirhandleMessageinvocations in.catch()to preserve unhandled-rejection visibility._applyScopedPatches,handleChildUpdate, andhandleStickyUpdatein45-child-view.jsare alsoasync.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 viastartViewTransitionrequires 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.applyPatchesis now explicitly assigned viaglobalThis.djust.applyPatches = applyPatchesat the end of12-vdom-patch.js. (Previously the function was reachable in test environments only byeval-host-scope hoisting, which async declarations don't honor under JSDOM.) Hook code that monkey-patchesapplyPatchesshould now address the namespace explicitly and treat the return value as aPromise<boolean>.Test surface migrated: 8 JS test files updated to
awaitapplyPatches/handleMessage/handleServerResponsecalls and switch todom.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.T012false positive on{% include %}partial templates (closes #1096) —T012(template usesdj-*event directives but missingdj-view) fired unconditionally for any template containingdj-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 inmanage.py check.Two opt-out paths now silence T012 for legitimate fragments:
- 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. - Global suppression:
DJUST_CONFIG = {"suppress_checks": ["T012"]}insettings.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-componentpresent) 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 toTestT012EventDirectivesWithoutViewinpython/tests/test_checks.pycover: 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. - Per-template marker: add
-
scripts/check-changelog-test-counts.pyregex missedasync def test_*— the test-counter pre-push hook'sPY_TEST_FN_REmatched onlydef test_*, silently undercounting pytest-asyncio test files (any module-levelasync 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 viatests/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 useasync def test_*.
[0.8.4rc1] - 2026-04-26
Fixed
- Inheritance resolution doubled filter-arg quotes —
|date:"M d, Y"rendered as"Apr 25, 2026"(closes #1081) —nodes_to_template_stringincrates/djust_templates/src/inheritance.rswas wrapping every filter arg in\"…\"when serializing the resolved-inheritance AST back to a template string. Butparse_filter_specsdeliberately 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"— surfacing as"Apr 25, 2026"in the rendered DOM. The fix emits the arg verbatim (|filter:{arg}) sinceparse_filter_specsalready preserves the source-form quotes; round-trip is now idempotent. Same fix applied to theNode::InlineIfbranch (a{{ x if cond else y | filter:"…" }}chain has the same shape). 29 regression cases intests/unit/test_filter_literal_args_1081.py+ 3 incrates/djust_templates/src/inheritance.rslock 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 hitnodes_to_template_stringand 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"Apr 25, 2026"(literal double-quotes wrapping the result) and{{ x|default:"fallback" }}rendering as"fallback". Investigation across all renderer code paths confirmed the existingstrip_filter_arg_quoteshelper (landed v0.5.2rc1 via #787) is invoked at every filter-application site:render_node_with_loader(Variable + InlineIf nodes, both call sites atcrates/djust_templates/src/renderer.rs:271,328) andget_valuefor inline filter chains (renderer.rs:1556 — inlinearg_str = arg_str[1..len-1]strip). When the issue was reopened with a more specific reproduction path ("DjangoDateFieldfrom 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 confirmedserialize_context(crates/djust_live/src/lib.rs:1776-1781) returns the bare ISO string —value.call_method0("isoformat")is passed straight throughinto_pyobjectwith noserde_json::to_stringor quote-wrapping. No reproducible code path produces the reported output onmain(= v0.8.3rc1). Newtests/unit/test_filter_literal_args_1081.pyships 24 cases covering every literal-arg shape from the issue body, follow-up comments, and reopen: (1)|datewith"M d, Y"/"F j, Y"/ single-quoted format / dotted-path field access; (2)|defaultwith 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"); (5)serialize_contextoutput shape — bare ISO string fordate/datetime/ list-of-dicts (the queryset+model+date path named in the reopen); (6) fullLiveView.render()with Django Model + DateField via the JIT serializer; (7)LiveView.render()with list of Model instances (_jit_serialize_queryset/_jit_serialize_modelpath); (8)render_with_difffull + 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.mdstaleness 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.jsfor JS Commands,python/djust/streaming.pyfor AI streaming primitives,crates/djust_vdom/src/parser.rsfor 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_mounthooks, 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_loadingon 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, Componentupdatecallback, 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?,@restattribute 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)** — newscripts/docs-lint.pywalks every markdown link indocs/(excluding the rendereddocs/website/site dir), parses[text](target.md)patterns, and reports any whose relative target doesn't resolve. Mirrorsmake roadmap-lintfrom Action #142 — manualmake docs-lint, with optionalVERBOSE=1to list every stale ref. Also wired into.pre-commit-config.yamlas 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 toforms/PYTHONIC_FORMS_IMPLEMENTATION.mdredirected to the canonicaldocs/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'sVary: Cookiehandling 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 indjust.theming.templatetags.theme_tagsemits<link href="/_theming/theme.css?p=<pack>&m=<mode>&r=<preset>">with cache-busting query params derived from the sameThemeManager.get_state()the view itself reads. Different pack/mode = different URL = guaranteed fresh fetch. Usage:<link rel="stylesheet" href="{% theme_css_link %}">. -
prose.cssfor@tailwindcss/typography↔ pack bridge (v0.8.2 drain — Group T, closes #1009) — newdjust_theming/static/djust_theming/css/prose.cssships pack-aware overrides for the typography plugin's--tw-prose-*variables. Opt in by addingprose-djustalongsideproseon 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_overrideflag forLIVEVIEW_CONFIG['theme'](v0.8.2 drain — Group T, closes #1013) —ThemeManager.get_state()readsdjust_theme_pack/djust_theme_presetcookies with priority over config defaults. Default behavior unchanged (back-compatTrue). Sites without a user-facing theme switcher can setLIVEVIEW_CONFIG['theme']['enable_client_override']: Falseto ignore cookie reads — prevents cross-project bleed on localhost where multiple djust apps share a cookie jar.
Fixed
-
.card/.alertoverflow:hidden for clean rounded corners (v0.8.2 drain — Group T, closes #1011) —djust_theming/static/djust_theming/css/components.css.cardand.alertselectors now setoverflow: 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_batchfallback for old-server compat (v0.8.1 reconcile drain — Group F, closes #1031) — themount_batchWebSocket 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 inlazyHydrationManager.inFlightBatch; if the websocket error handler seesmount_batchorUnknown message typein the error string, it invokeshandleMountBatchFallback()which iterates the stashed mounts and falls back to per-view mount calls. Idempotent (clearsinFlightBatchbefore iterating) so a late-arriving successful response can't double-trigger. 7 new JSDOM tests undertests/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 returningf"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 matchobservability/views.py:401's existing pattern: log the exception server-side vialogger.exception(...), return a generic"Malformed JSON body — see server logs"message. Theinvalid_jsonerror code is unchanged, so callers that branch onerrorkeep 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:386previously swallowed cache-put exceptions with a barecatch (_e) {}. Now logs the failure viaif (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_underand the per-segment budget constants (TARGET_PER_EVENT_S,TARGET_LIST_UPDATE_S,TARGET_WS_MOUNT_S) moved fromtests/benchmarks/test_request_path.pyintotests/benchmarks/conftest.pyfor shared scope across benchmark files. - #1034: replaced the
TARGET_LIST_UPDATE_S * 20magic-number budget for the WS-mount benchmark with a namedTARGET_WS_MOUNT_S = 0.1constant — rationale lives in the constant name, not the multiplier. - #1028: extracted the duplicated
_make_userfactory intopython/djust/tests/conftest.pyasmake_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 intest_stack_trace_exposure.pywith a behavior-level test that triggers a serialize-error via a sentinel-ladenRuntimeErrorand asserts neither the sentinel nor the exception class name reach the response body. Defends against regressions even if the leak vector moves.
- #1036:
Added
-
make roadmap-lint— mechanical ROADMAP-vs-codebase drift check (Action #142, closes #1057) —scripts/roadmap-lint.pyparses the "Not started" entries inROADMAP.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 thepipeline-roadmap-auditskill instead. Exit code 0 unless drift exceeds threshold (25 suspect entries). Run viamake roadmap-lintormake roadmap-lint VERBOSE=1. -
Pre-push hook for
# noqa: F822in__all__patterns (Action #146, closes #1061) —scripts/check-noqa-f822.shflags newnoqa: F822annotations introduced inpython/**/*.pyortests/**/*.pysince the last push. Ruff silencespy/undefined-exportwithnoqa: F822, but CodeQL flags it as a security alert later — the canonical fix is aTYPE_CHECKING-conditional import (PR #924 pattern). Hook fires only on changed files (incremental); pass--allto scan the whole tree manually.
[0.8.0rc1] - 2026-04-25
Added
-
@actiondecorator — 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.0dj-form-pendingattribute (PR #1023):dj-form-pendingcovers the in-flight client UX (during the network round-trip),@actioncovers 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
@actiondecorator indjust.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
@actionand 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 viaContextMixin.get_context_data()). - New
-
dj-form-pendingattribute — React 19useFormStatusequivalent (v0.8.0) — any element nested inside a<form dj-submit>can declaredj-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 adata-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 thehiddenattribute while pending (idle label that disappears during submit)show— element is hidden by default and visible while pending (loading spinner / "Saving…" text)disabled—disabled = truewhile pending; original disabled state restored on resolve. User-disabled elements stay disabled (the helper tracks pre-pending state indata-djust-form-pending-was-disabled).
State is set BEFORE the network round-trip and cleared in a
finallyblock 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 in09-event-binding.js—_setFormPending(form, pending)helper + 1-line wiring into_handleDjSubmit. Bundle delta: ~80 B gzipped. Covered by 8 JS regression tests intests/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.mddocuments two reusable patterns surfaced during the v0.7.x check-refinement work:- Whitespace-preserving redaction for line-number-aware regex
scanners (canonical:
_strip_verbatim_blocksfrom 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_checkfrom 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/LiveComponentmust be added to_FRAMEWORK_INTERNAL_ATTRSto prevent leakage intoget_state(). Source: ADR-012 / issue #962 / PR #1002.
- Whitespace-preserving redaction for line-number-aware regex
scanners (canonical:
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) andpython/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-elementtimesarray indexed bytime.time()call count. py3.14 introduced extratime.time()calls inside the asyncio scheduler path (someloop.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 extratime.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 intest_presence_list_scales_linearlystill 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.W001contrast-checks the active preset only by default (v0.7.3, #1005) —check_preset_contrastpreviously iteratedget_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 everymanage.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 readsDJUST_THEMING.contrast_check_scope(default:"active") and the active scope iterates only the preset configured inLIVEVIEW_CONFIG.theme.preset— same settingcheck_preset_validreads. 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_validalready 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.A070no longer false-positives on{% verbatim %}-wrappeddj_activityexamples (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 frommatch.start()stay accurate for matches OUTSIDE the region. The scanner's existing iteration over_DJ_ACTIVITY_TAG_REruns against the redacted source. Real uninstrumented{% dj_activity %}calls outside any verbatim block continue to fire A070 unchanged. Covered by 12 regression tests inpython/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.C011now catches stale/placeholderoutput.css, not just totally-missing files (v0.7.3, #1003) —_check_missing_compiled_cssinpython/djust/checks.pypreviously tested onlyos.path.exists(). A committed-but-staleoutput.css(e.g. a placeholder/* Run tailwindcss ... */) silently passed the check, the site rendered without any Tailwind utilities, andmanage.py checkemitted no warning. Reported by the docs.djust.org team after hitting it at launch — fresh-clone +make devproduced 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 (tailwindcssbanner OR@layerdirective) in the first 512 bytes. The existingos.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 KBTailwind output, hand-rolled@layerstylesheet) 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-inlineattribute (v0.7.2, #991) — opt-in horizontal layout forforms.RadioSelectfields without writing any new Python. Users addwidget=forms.RadioSelect(attrs={"data-dj-inline": "true"})to aChoiceFieldand 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 asinline-flexwith sensible spacing, full keyboard navigation, and the browser's native focus ring preserved. Composes with anything that renders a DjangoRadioSelect(plainforms.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 ofdocs/website/guides/forms.mdwith the API, the why-data-attribute reasoning, and examples for customizing the visual treatment + multi-field forms. Covered by 12 regression tests intests/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_ATTRSfilter 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_ATTRSfrozenset inpython/djust/live_view.pyto prevent ~25 framework-set attrs (sync_safe,login_required,template_name, ...) from leaking intoget_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 readingself.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. Seedocs/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.ymlruns every Monday at 06:00 UTC (plus manualworkflow_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 atech-debt+ newcloud-integrationlabel issue viaactions/github-script@v7with 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 undertests/cloud_integration/and auto-skip whenDJUST_CLOUD_INTEGRATIONisn'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_templateUUID-prefix convention fors3_events(v0.7.2, #964) —djust.contrib.uploads.s3_events.parse_s3_eventextractsupload_idby finding the first UUID-shaped path segment in the S3 object key; apps whosekey_templatedoesn't produce such a segment silently fall back to the full key asupload_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 recommendedkey_templateshapes (uploads/<uuid>/<filename>and<tenant>/<uuid>/<filename>); (b) aDEBUGlog entry fires on thedjust.contrib.uploads.s3_eventslogger whenever fallback happens, naming the offending key — so enablingDEBUGlogging once is enough to diagnose a silent hook; (c) a "Key-template convention fors3_events" section has been added todocs/website/guides/uploads.mdwith a debugging recipe and a pointer to the "custom upload-id routing" escape hatch (viax-amz-meta-upload-id/ JWT / DB lookup). Covered by 3 new regression tests intests/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_safelysets"__str__": str(obj)on every dict it produces so{{ obj }}in a Rust-engine template can match Django's defaultstr(obj)semantics. The RustValue::ObjectDisplay 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 }}(whereclaimantserializes 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 isValue::Objectand 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 byFromPyObject). Covered by 5 Rust unit tests incrates/djust_core/src/lib.rs::testsand 13 Python integration tests intests/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_serverNameError on module load whenwatchdogis not installed (v0.7.2, #994) — thetry/except ImportErrorblock atdev_server.py:13-19setsWATCHDOG_AVAILABLE = Falsebut the class statementclass DjustFileChangeHandler(FileSystemEventHandler)on line 25 referenced the symbol unconditionally. When watchdog is absent, class definition time crashes withNameError: name 'FileSystemEventHandler' is not defined, which in turn breakspython manage.py checkin any djust install without the[dev]extra (becausedjust.checks.check_hot_view_replacementimportsWATCHDOG_AVAILABLEfromdjust.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: theexcept ImportErrorbranch now defines stubFileSystemEventHandler,FileSystemEvent, andObserverclasses purely to satisfy the class statements below at import time.HotReloadServer.start()already short-circuits onWATCHDOG_AVAILABLE = False, so the stubs are never instantiated in a running process. Covered by 3 regression tests intests/test_dev_server_watchdog_missing.pythat block watchdog via asys.meta_pathfinder and verify (a)djust.dev_serverimports 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 %}indjust.templatetags.live_tagsemits<meta name="djust-api-prefix" content="...">. The content is derived via Django'sreverse()so it honors bothFORCE_SCRIPT_NAMEand any customapi_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) andwindow.djust.apiUrl(path)(prefix + path joiner with slash normalization).djust.call()(48-server-functions.js) now routes throughdjust.apiUrl()— the last remaining hardcoded/djust/api/reference in the client bundle is gone. Priority: explicitwindow.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 intest_client_config_tag.py, 6 JS inapi_prefix.test.js, 1 regression inserver_functions.test.jsassertingdjust.callhonors the meta tag under a forced script prefix). Bundle size delta: +148 B gzipped (50030 → 50178 B). Docs: "Sub-path deploys" section added todocs/website/guides/server-functions.mdanddocs/website/guides/http-api.md. Follow-up issue #992 filed for the same class of bug in03b-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 onpulldown-cmark 0.12with three safety guarantees wired in at the crate level: raw HTML in the source is escaped (Options::ENABLE_HTMLis never set; because pulldown-cmark 0.12 still emitsEvent::Html/Event::InlineHtmlwhen that flag is off,sanitise_eventre-routes those events toEvent::Textso 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 helperdjust.render_markdown(src, **opts)returning aSafeString, and the PyO3 functiondjust._rust.render_markdown. Kwargs:provisional,tables,strikethrough,task_lists. Note on deviation from plan:autolinkswas dropped from the public surface — pulldown-cmark 0.12 does not expose aGFM_AUTOLINK/ENABLE_AUTOLINKoptions 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 forvbscript:,data:, mixed-caseJavaScript:, 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_extclose the most-requested gaps in the alternative reactive admin:DjustModelAdmin.change_form_widgets/change_list_widgetsclass attributes accept any list ofLiveViewsubclasses; each is embedded via{% live_render %}on the matching admin page. Permission filtering honourspermission_requiredon the widget class. See docs/website/guides/admin-widgets.md.@admin_action_with_progress(indjust.admin_ext.progress) turns anyDjustModelAdminaction into a background daemon thread and redirects the user to aBulkActionProgressWidgetpage 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 flipsdoneandcancelled. Queryset is eagerly pinned to PKs before the thread starts (no lazy-eval foot-guns). Cancellation is cooperative — clicking Cancel flipsprogress.cancelled = True; the action body must periodically checkif progress.cancelled: returnto actually stop (Python cannot safely interrupt a running thread mid-statement).- Server-side permission enforcement —
@admin_action_with_progress(permissions=[...])stampsallowed_permissionson the wrapped action;ModelListView.run_actionnow callsrequest.user.has_perms(allowed)before dispatching the action and raisesPermissionDeniedif the user lacks any declared perm. Closes the gap wherehas_*_permissionreturns True for any staff user. - Bounded server state:
_JOBSis LRU-capped at_MAX_JOBS = 500(oldest entries evicted on insert once the cap is reached), andJob.message/Job.errorare individually truncated to_MAX_MESSAGE_CHARS = 4096on eachprogress.update(...)call.Job.erroris a generic user-facing string ("Action failed — see server logs for details"); the raw exception text lives only on the server-sideJob._error_rawattribute and is always logged at ERROR level vialogger.exception(logger namedjust.admin_ext.progress). - New setting:
DJUST_ASGI_WORKERS(default1) — declares the number of ASGI workers in the deployment. Gates the A073 system check (fires only whenDJUST_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 raisesTemplateSyntaxErrorat render time. - Two new system checks:
djust.A072(warning) fires if a non-LiveViewclass is registered in a widget slot;djust.A073(info, gated onDJUST_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_JOBSlimitation 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 inpython/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>carryingdata-djust-activity,data-djust-visible, and — when not visible — the HTMLhiddenattribute plusaria-hidden="true". The body is rendered unconditionally in every pass so local state isn't lost.ActivityMixin(composed intoLiveViewAFTERStickyChildRegistry, BEFOREView) provides the server-side API:set_activity_visible(name, visible),is_activity_visible(name), declarativeeager_activities: frozensetclass attr, and an internal FIFO deferred-event queue (cap 100, overridable viaactivity_event_queue_cap) drained by the WebSocket consumer after everyhandle_event/handle_infodispatch. Client runtime (python/djust/static/djust/src/49-activity.js) exposeswindow.djust.activityVisible(name)and dispatches a bubblingdjust:activity-shownCustomEvent when a panel flips hidden → visible. The event-dispatch gate in11-event-handler.jsdrops events whose trigger sits inside a hidden non-eager activity client-side (stamping_activityon all other events for server-side deferral). The VDOM patcher in12-vdom-patch.jsskips subtree patches targeting nodes inside a hidden non-eager activity so DOM state is preserved. Two new system checks:A070(Warning — missingnameargument) andA071(Error — duplicate activity name within one template). Seedocs/website/guides/activity.mdfor the full guide +{% if %}/{% live_render %}/ sticky /dj-prefetchcomparison matrix. Demo atexamples/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 onmouseleavebefore the debounce fires) and immediately ontouchstart— 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-priorityfetch+AbortControllerwhenrelListdoesn't advertise'prefetch'). Same-origin only;javascript:/data:URLs blocked; dedup'd per URL via a Set thatwindow.djust._prefetch.clear()wipes on SPA navigation. Opt out per-link withdj-prefetch="false". Respectsnavigator.connection.saveData. New client surface:window.djust._intentPrefetchfor test/diagnostic access. Scope: client-side only — no new server endpoint. Contract:dj-prefetchis intended for author-controlled navigation links only; don't put it on links that perform state-changing GETs (see the module header inpython/djust/static/djust/src/22-prefetch.jsfor the full safety contract). Seedocs/website/guides/prefetch.mdfor 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_functionand invoke it from JavaScript asawait 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, noapi_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) returns400 invalid_body. This deliberately removes the ambiguity where a caller's own field namedparamswould be silently unwrapped and every sibling key dropped. The dispatcher reuses the ADR-008 pipeline unchanged: parameter coercion viavalidate_handler_params,@permission_requiredgating viacheck_handler_permission, and@rate_limitvia the same LRU-capped_rate_bucketsOrderedDict. Both sync andasync deffunctions are supported via_call_possibly_async. Stacking@event_handlerand@server_functionon the same method raisesTypeErrorat 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(inpython/djust/api/dispatch.py),iter_server_functions. New client modulepython/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. Seedocs/website/guides/server-functions.mdfor the full API reference, error-code table, and comparison vs.@event_handlerand@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_handlerdispatch (state_before/state_after), then lets developers scrub back through the timeline and jump to any past state. The server restores the snapshot viasafe_setattrand re-renders through the normal VDOM patch pipeline. Opt-in per view (time_travel_enabled = Trueon theLiveViewsubclass); zero cost when disabled. Gated onDEBUG=Trueat 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 viaLIVEVIEW_CONFIG["time_travel_max_events"]). New moduledjust.time_travel(EventSnapshot,TimeTravelBuffer,record_event_start,record_event_end,restore_snapshot). New inbound WS frametime_travel_jump+ outboundtime_travel_stateack, plustime_travel_eventframes pushed after every recorded snapshot so the debug panel timeline populates incrementally (client CustomEventdjust:time-travel-event). Instrumentation wraps all three dispatch branches (actor, component, view handler) and records permission-denied / validation-failed events with anerrormarker. 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 inrestore_snapshotremoves 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 eventsdjust:time-travel-stateanddjust:time-travel-event(CustomEvents). New system checksdjust.C501(info — global switch on) anddjust.C502(error — non-positivetime_travel_max_events). Beyond Redux DevTools: server-side so no client state store; beyond Phoenix LiveView's debug tools which are telemetry-only. Seedocs/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 = Trueon a LiveView class returns aStreamingHttpResponsethat 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 (noContent-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 emitsX-Djust-Streaming: 1for observability and omitsContent-Length. Seedocs/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 onDEBUG=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 checkdjust.C401warns when HVR is enabled butwatchdogis not installed. New client eventdjust:hvr-applied(CustomEvent). Zero cost in production.See
docs/website/guides/hot-view-replacement.md.
[0.6.0rc1] - 2026-04-23
Documentation
- CSS
@starting-styleguide section (v0.6.0) — documents that browser-native@starting-styleworks unmodified with djust's VDOM insert path. No new djust attributes or JS — the feature is pure CSS. Guide section indocs/website/guides/declarative-ux-attrs.mdincludes a quick-start example, a side-by-side comparison vsdj-transition(browser support, runtime cost, per-element customization), interop notes withdj-removefor enter+exit coverage, and caveats around@supportsgating 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 atv99.0.0— each retains a shim-only__init__.pythat re-exports fromdjust.<name>and emits aDeprecationWarning. 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](withdjust[tenants-redis]anddjust[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, writesartifacts/profile-<timestamp>.{txt,pstats}; exits non-zero on target-miss for CI). Newtests/benchmarks/test_request_path.pywith 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. Newdocs/performance/v0.6.0-profile.mdreporting 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). Newmake profiletarget wired to the harness (the priormake profileruntime-stats target is nowmake 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 checksdjust.C301/C302/C303guard config ranges. - LiveView state snapshots: opt-in per view via
enable_state_snapshot = Trueon aLiveViewsubclass. Client captures JSON-serializable public state ondjust:before-navigate; server restores via_restore_snapshot(state)in lieu ofmount()when the user hits back. Views override_should_restore_snapshot(request)to reject stale snapshots. System checkdjust.C304warns when a snapshot-opt-in view declares attributes matching PII naming patterns. - Mount batching: when multiple
dj-lazyLiveViews hydrate together, the client sends onemount_batchWebSocket frame instead of N separatemountframes. Server responds with onemount_batchcarrying all rendered views; per-view failures are isolated in afailed[]array (atomicity relaxed so one bad view doesn't kill the batch). Opt out viawindow.DJUST_USE_MOUNT_BATCH = false. - New client module
46-state-snapshot.js(~120 LOC); new senders ondjust._sw.cacheVdom/lookupVdom/captureState/lookupState. registerServiceWorker({vdomCache: true, stateSnapshot: true})gates the new behaviors alongside existinginstantShell/reconnectionBridgeoptions.
See
docs/website/guides/service-worker.md. - VDOM patch cache: per-URL HTML snapshots served instantly on popstate,
then reconciled against the live WebSocket mount reply. Configurable via
Changed
LiveViewConsumer.handle_mount()accepts newstate_snapshotkwarg; dispatches to the snapshot-restore path when the view opts in and the payload'sview_slugmatches. New methodhandle_mount_batch()+_mount_one()collector seam enable the mount-batch path without regressing the single-viewmountflow.
Security
-
State snapshots are JSON-only (no pickle).
safe_setattrblocks dunder keys and private (_-prefixed) attributes during restoration. SW enforces a 256 KB upper bound onstate_jsonpayloads; client clamps at 64 KB. System checkdjust.C304warns when snapshot-opt-in views declare attribute names matchingpassword|token|secret|api_key|pii. -
Sticky LiveViews (v0.6.0) — Phoenix
live_render sticky: trueparity. Shipped across three PRs: #966 (Phase A — embedding primitive), #967 (Phase B — preservation acrosslive_redirect), #969 (Phase C — ADR-011, user guide, demo app). Mark a LiveView class withsticky = True+sticky_idand 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 survivelive_redirectnavigation. Use case: app-shell widgets (audio players, sidebars, notification centers), wizard preview panes.User-facing API
LiveView.sticky: bool = False+sticky_id: Optional[str] = Noneclass attrs.{% live_render "dotted.path" sticky=True %}template tag (validates class opt-in at render time;TemplateSyntaxErroron mismatch).[dj-sticky-slot="<id>"]slot markers in destination layouts.djust:sticky-preserved/djust:sticky-unmountedCustomEvents for lifecycle hooks (reasons:server-unmount,no-slot,auth)._on_sticky_unmount()per-instance hook (default: cancels pendingstart_asynctasks).
Wire protocol
child_update(Phase A) — scoped VDOM patches for embedded non-sticky children.sticky_hold(server→client, sent BEFOREmountonlive_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 latesticky_holdwould reattach auth-revoked views.sticky_update(server→client) — per-child VDOM patches scoped to[dj-sticky-view="<id>"]via a newapplyPatches(patches, rootEl)variant in12-vdom-patch.js(whenrootElis 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.js—stickyStashMap;stashStickySubtrees()(detach on outbound nav),reconcileStickyHold(views)(drop non-authoritative),reattachStickyAfterMount()(replace[dj-sticky-slot]with stashed subtree viareplaceWith()— DOM identity preserved),handleStickyUpdate(msg)(scoped patch apply),clearStash()(abnormal-close cleanup).18-navigation.jscallsstashStickySubtrees()BEFORE outboundlive_redirect_mount(and beforepopstate-triggered redirects).03-websocket.jsonclose callsclearStash()on abnormal disconnect.[dj-root]audit across40-dj-layout.js,24-page-loading.js,12-vdom-patch.jsautofocus 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_MODULESprefix-allowlist gates dotted-path resolution (unset = permit-all, backward compatible).sticky_idHTML-escaped via server-sideescape()+CSS.escapeon client-side selectors.- Client stash bounded by developer-authored content; idempotent
stashStickySubtreescoalesces duplicates; cleared on abnormal WS close. - Inbound
sticky_update/sticky_holdframes 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 intests/unit/test_sticky_preserve.py. - 7 Phase A tests in
tests/js/child_view.test.js+ 15 Phase B/C tests intests/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:
skipMountHtmlmount branch reattaches sticky subtrees (Fix F1);disconnect()drains_sticky_preservedso 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-slotunmount.
-
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 viaNumber+isFinite+ clamp[0, 30000]— trailing garbage rejects to fallback),dj-flip-easing(defaultcubic-bezier(.2,.8,.2,1), strings containing;"'<>rejected to defeat CSS-property-breakout). Respectsprefers-reduced-motion. Nested[dj-flip]isolated viasubtree: false. Author-specified inlinetransformon 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 stableid=(Rust VDOM emits MoveChild). Lands instatic/djust/src/44-dj-flip.js(~260 LOC). 12 JSDOM tests intests/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 viabuild_tag(). Shimmer@keyframesemitted once per render viacontext.render_context. Integrates with existingdj-loadingshorthand and with{% if async_pending %}server blocks. 21 Python tests intests/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.ResumableUploadWriterwraps any existingUploadWriter(S3 MPU, GCS, Azure, tempfile) and persists chunk-level state into a pluggableUploadStateStore. Two stores ship in core:InMemoryUploadState(default, single-process) andRedisUploadState(requiresdjust[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 endpointGET /djust/uploads/<upload_id>/status(session-scoped, cross-user probes blocked). Client-side IndexedDB cache in15-uploads.jslets 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 acrosspython/djust/uploads/(__init__.pymodified,resumable.py,storage.py,views.pyadded),python/djust/websocket.py,python/djust/static/djust/src/15-uploads.js(+03-websocket.jsdispatch), full wire-protocol spec + failure-mode + security analysis indocs/adr/010-resumable-uploads.md. 44 unit tests inpython/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 intests/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_presignedmodule lets clients upload directly to S3 via a pre-signed URL; djust only signs and observes completion via S3 event webhook. Newdjust.contrib.uploads.gcs.GCSMultipartWriteranddjust.contrib.uploads.azure.AzureBlockBlobWritership as first-classUploadWritersubclasses with consistent error taxonomy (UploadError,UploadNetworkError,UploadCredentialError,UploadQuotaError, re-exported fromdjust.uploads). Client-sidedjust.uploads.uploadPresigned(spec, file, hooks)streams bytes straight to object storage via XHR (progress viaxhr.upload.onprogress), bypassing the WS upload machinery. Optional extras:djust[s3],djust[gcs],djust[azure]. ~650 LOC + 50 regression tests (mocked SDKs) acrosspython/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. Seedocs/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 fordj-virtualvariable-height mode atdocs/website/guides/virtual-lists.md(#952). -
dj-virtual variable-height items via ResizeObserver — closes #797 — PR #796 shipped
dj-virtualwith fixed-height items only. This adds opt-in variable-height support via a newdj-virtual-variable-heightboolean attribute. Implementation: ResizeObserver per rendered item feeds aMap<index, number>height cache; a lazily-computed prefix-sum array drives offset math and the virtual spacer total. Unmeasured items fall back to a configurabledj-virtual-estimated-height(default 50px). Fixed-height mode (dj-virtual-item-height="N") is unchanged — tested explicitly as a regression guard. Updated29-virtual-list.js(~180 LOC net) and 4 new JSDOM cases intests/js/virtual_list.test.jscovering 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.pyparses phrases likeN JSDOM cases,N regression tests,N unit tests,N test cases,N parameterized casesin the[Unreleased]section, resolves every backtickedtests/js/*.test.js/python/djust/tests/*.py/tests/unit/*.pypath 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.yamlas a local hook scoped to^CHANGELOG\.md$and exposed asmake check-changelog. Self-tested by 7 cases intests/test_changelog_test_counts.pycovering match/mismatch, JSDOM-vs-py file resolution, multi-file summing, delta ignore, and missing-section tolerance. -
Tooling: CodeQL triage script — closes #916 —
scripts/codeql-triage.sh [rule-id]paginates/repos/{owner}/{repo}/code-scanning/alerts?state=openviagh apiand emits a markdown triage doc grouped byrule.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 inscripts/README.md. -
Tooling: CodeQL sanitizer MaD model — closes #934 — new extension pack at
.github/codeql/models/(qlpack.yml +djust-sanitizers.model.yml) teaches CodeQL thatdjust._log_utils.sanitize_for_log()is a log-injection sanitizer. Referenced from.github/codeql/codeql-config.ymlvia a newpacks:section. Closes the class of false-positivepy/log-injectionalerts we've been dismissing individually. Verification lands with the next main-branch CodeQL scan. See.github/codeql/README.mdfor the tuple shape, fallback plan (hand-writtenLogInjectionFlowConfigurationoverride), 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 inLiveViewConsumer. 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.
- 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
(
Fixed
-
Framework cleanup (closes #762, #890) — djust.A010 / A011 system checks now recognize proxy-trusted deployments: when
SECURE_PROXY_SSL_HEADER+DJUST_TRUSTED_PROXIESare 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.) fromLiveView.get_state(), the WS_snapshot_assignschange-detection path, and the_debug.state_sizesobservability payload — user's reactive state is no longer swamped by framework config. Non-breaking fix via a newlive_view._FRAMEWORK_INTERNAL_ATTRSfrozenset; attribute names unchanged. 14 new regression tests inpython/djust/tests/test_a010_proxy_trusted_890.pyandpython/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-keyattribute (configurable viadj-virtual-key-attr), falling back to index when absent — cached heights survive item reorders (#951). Consolidated JSDOM test helpers attests/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 toscripts/build-client.shto fail fast iftests/js/_helpers.jsever leaks into the production bundle. -
Hygiene batch (closes #791, #794, #795, #818, #948) — bumped
ruff-pre-commitfrom v0.8.4 to v0.15.11 (#948) and appliedruff formatto all resulting drift (#791 — expanded beyond the original 5 files due to modern-ruff disagreements; 19 files total acrosspython/djust/andtests/). Addedlogger.debugnotice incomponents/suspense.pywhen{% dj_suspense await=X %}receives a non-AsyncResult value so a typo surfaces during development (#794), simplified a redundantor not value.okcheck nearsuspense.py:138given the AsyncResult mutually-exclusive-flag invariant (#795), wrapped the namespaceddata-hookattribute value withdjango.utils.html.escape()for defense-in-depth intemplatetags/live_tags.py(#818), and corrected stale test-count claims in two historical CHANGELOG bullets (test_assign_async.py11 → 18,test_suspense.py11 → 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.pydeliberately sanitize error payloads (don't echoRuntimeError/ internal method names to clients; send to server logs instead). Tests now verify the sanitized contract ("server logs"inerror, handler_name / session_id echo) rather than the leaked details. Fixestest_api_response.py::test_dispatch_serialize_str_missing_method_returns_500,test_observability_eval_handler.py::test_eval_500_when_handler_raises, andtest_observability_reset_view.py::test_reset_500_when_mount_raises. #921: expanded open-redirect audit beyond PR #920 —mixins/request.pynow validateshook_redirectreturned by developer-definedon_mounthooks viaurl_has_allowed_host_and_scheme, falling back to"/"and logging a WARNING on unsafe targets.auth/mixins.pyLoginRequiredLiveViewMixin.dispatchnow validates the computed login URL as defense-in-depth against misconfiguredsettings.LOGIN_URL, falling back to"/accounts/login/". #922: 7 new regression tests inpython/djust/tests/test_security_redirects_paths.py—javascript: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-siteLOGIN_URLfallback. #910: added upper-bound ceilings to all runtime + dev dependencies inpyproject.toml(e.g.requests>=2.28,<3,orjson>=3.11.6,<4,nh3>=0.2,<1). Prevents uncontrolled major bumps duringuv lockrefresh (see PR #909 which caught Django 6.x resolving under>=4.2). Ceiling policy documented in a comment above[project.dependencies]. Verified withuv lock— only material change isredis7.3 -> 6.4 (stays under new<7ceiling). -
UploadMixin defensive replay for schema-changed configs — closes #892 —
_restore_upload_configsnow wraps each per-slotallow_upload(**cfg)in try/exceptTypeError. 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 = 1for future explicit migrations. Regression tests intests/unit/test_mixin_replay_schema_cross_loop_892_896.py.
- the mismatched kwarg, then falls back to
-
NotificationMixin cross-loop restore — closes #896 —
_restore_listen_channelsnow detects when thePostgresNotifyListenersingleton 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 newPostgresNotifyListener.reset_for_new_loop()classmethod to drop the singleton before replay. The pre-check inspectslistener._loop.is_closed(); a per-channelexcept RuntimeErrorbranch handles the race where the loop closes between the pre-check and theensure_listeningcall (resets and retries once). Prevents silent NOTIFY drops on cross-loop restore. Regression tests intests/unit/test_mixin_replay_schema_cross_loop_892_896.py. -
Observer JS — closes #879, #880, #881, #882 — #879:
37-dj-mutation.jsand38-dj-sticky-scroll.jsdocument-level root observers now detect attribute REMOVAL on already-observed elements (viaattributes: true+attributeFilter: ['dj-mutation']/['dj-sticky-scroll']) and call the module's teardown helper. Previously removing the attribute from an element left a staleMutationObserver+ scroll listener attached. #880: documented theMap-vs-WeakMapchoice in39-dj-track-static.js— the reconnect-diff iterates all tracked elements to compare snapshot URLs, andWeakMapdoes not support iteration; theisConnectedcheck in_checkStalehandles detached elements. #881: documented unconditional scroll-to-bottom on install in38-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 intests/js/dj_mutation.test.js— nodj-mutation-fireCustomEvent fires when the element is removed before the debounce timer expires (existing_tearDownDjMutationpath correctly clears the pending timer on removal).
Tests
- dj-transition-group follow-ups — closes #905, #906 —
#905 The VDOM
RemoveChildintegration test intests/js/dj_transition_group.test.jswaited 700 ms per run for the default dj-remove fallback timer. Pinneddj-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 toinnergets 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-appliedruff 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 viaparser.parse(("endform_array",))butFormArrayNode.rendernever rendered that nodelist — users' inner template markup silently disappeared. Fixed by rendering the nodelist once per row withrow,row_index, andforloop(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 missingname=attribute:TagInput._render_customrendered a visible "type to add"<input class="tag-input-field" placeholder="...">with noname=, 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 wheneverself.nameis non-empty; hidden value ishtml.escape'd. #933 gallery/registry.py dead discover_* path:discover_template_tags()anddiscover_component_classes()were public helpers exported fromdjust.components.gallery.__init__butget_gallery_data()never called them — a developer adding a new@register.tagorComponentsubclass without updating the curatedEXAMPLES/CLASS_EXAMPLESdicts had that new thing silently missing from the rendered gallery. Fixed by wiring both helpers intoget_gallery_data()as a cross-check: any registered tag / component class missing an example entry emits alogger.debugwarning naming the missing entries, and discovery failures are caught so the gallery never breaks at runtime. 14 regression tests acrosspython/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 in42-dj-remove.jsso_finalizeRemovaland_cancelRemovalno longer duplicate the clearTimeout + removeEventListener + observer.disconnect + _pendingRemovals.delete block (Stage 11 nit from PR #898). Added a debug warning (gated onglobalThis.djustDebug) when_parseRemoveSpecencounters a 2-token value likedj-remove="fade-out 300"— previously silent fall-through. 2 new JSDOM regression cases intests/js/dj_remove.test.js(12/12 passing). -
dj-transition edge cases — closes #886, #887, #888 — #886
_parseSpecin41-dj-transition.jsnow rejects comma, paren, and bracket separators up front (returnsnulland emits a debug warning gated onglobalThis.djustDebug) instead of lettingclassList.addthrowInvalidCharacterErrorat runtime — matches the dj-remove #901 loud-in-debug / silent-in-prod pattern. #887 Thecleanupcallback (bothtransitionendhandler and 600 ms fallback path) now guards withel.isConnected— if the element has been detached from the DOM before cleanup fires, we skip classList and listener work and just drop the_djTransitionStateentry. Prevents any futureparentNode.Xaccess from NPE'ing on a detached node. #888 Unskipped the two previously-flakytransitionendtests intests/js/dj_transition.test.jsby swapping timing-sensitivesetTimeout(..., 30)waits for synchronousel.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 therequires-pythonconstraint 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 inset_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-versiontopy310and[tool.mypy] python_versionto3.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) — PhoenixJS.hide/phx-removeparity. When a VDOM patch, morph loop, ordj-updateprune would physically remove an element carryingdj-remove="...", djust delays the actualremoveChild()until the CSS transition the attribute describes has played out (or a 600 ms fallback timer fires, overridable viadj-remove-duration="N"). Two forms: three-tokendj-remove="opacity-100 transition-opacity-300 opacity-0"matches thedj-transitionshape (start → active → end), and single-tokendj-remove="fade-out"applies one class and waits fortransitionend. If a subsequent patch strips thedj-removeattribute from a pending element, the pending removal cancels and the element stays mounted. Public hookwindow.djust.maybeDeferRemoval(node)is called from five removal sites in12-vdom-patch.js. Descendants of a[dj-remove]element are NOT independently deferred — they travel with their parent, matching Phoenix. Newstatic/djust/src/42-dj-remove.js. 10 JSDOM cases intests/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 settingdj-transition(enter) anddj-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: shortdj-transition-group="fade-in | fade-out"(pipe-separated halves, each accepting the same 1- or 3-token shape asdj-transition/dj-remove), and long form with baredj-transition-groupplusdj-group-enter/dj-group-leaveon 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 viadj-group-appearon the parent. Never overwrites author-specifieddj-transitionordj-removeon a child — escape hatch for per-item overrides. A per-parentMutationObserverpicks up newly appended children; a document-level observer handles parents that arrive via VDOM patch or attribute mutation. Newstatic/djust/src/43-dj-transition-group.js. 11 JSDOM cases intests/js/dj_transition_group.test.jscover short-form parsing, invalid input, manual_handleChildAdded, respect for pre-existing per-child attrs, default leave-only initial wiring,dj-group-appearenter opt-in, post-mount append via observer,_uninstalldisconnecting the per-parent observer, parent-removal auto-cleanup via the root observer, end-to-end VDOMRemoveChilddeferral through the wireddj-remove, and cancel-on-strip uninstalling the per-parent observer whendj-transition-groupis removed at runtime (symmetric withdj-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-importnotes + 7 misc note-level alerts. Real refactor: extractedContextProviderMixinfromlive_view.pyto a new_context_provider.pymodule socomponents/base.pycan import it without creating a module-level cycle back throughlive_view -> serialization -> components/base.live_view.pyre-exportsContextProviderMixinfor back-compat (existing user code importingfrom djust.live_view import ContextProviderMixinkeeps working). Closes 3 real cyclic-import alerts (#2112, #2113, #2114). The remaining ~28 theming cyclic-import notes (inmanager.py,registry.py,theme_css_generator.py,pack_css_generator.py,theme_packs.py,manifest.py,css_generator.py) are allfrom ... importstatements 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 3py/mixed-returnsvia mechanical cleanup:theming/inspector.py(added 405 Method-Not-Allowed fallback),admin_ext/views.py(replaced barereturnwithreturn Noneinrun_action),management/commands/djust_audit.py(explicitreturn Nonefrom allhandle()branches). Dismissed 3py/unused-global-variablefalse positives (lazy-init cache pattern incomponents/icons.py:_icon_sets_cache,theming/theme_packs.py:_theme_imports_done,observability/log_handler.py:_installed_handler— same pattern as_psycopgdismissed in #2104/#2105) and 1py/ineffectual-statementfalse positive (tutorials/mixin.py:371—await corois 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-broadexcept Exception: passto specific exception types where the call surface was knowable, and addedlogger.debug(...)(withimport logging; logger = logging.getLogger(__name__)where not already present) for optional-feature probes incomponents/gallery/views.py(optionaldjust_themingstatic CSS link),components/icons.py(optionalDJUST_COMPONENTS_ICON_SETSsetting),auth/admin_views.py(optionaldjango-allauthOAuth stats, 2 sites),auth/djust_admin.py(optional allauth registry), andmixins/context.py(best-effort descriptor resolution). Annotated "skip invalid numeric input" sites with justification comments (+pass→continuefor clarity) acrosscomponents/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 inchecks.py,components/base.py(optional@event_handlerdecoration),mixins/waiters.py(idempotent waiter removal),observability/dry_run.py(best-effort bulk-op count),theming/management/commands/djust_theme.py, andtheming/templatetags/theme_tags.py. Re-export incomponents/templatetags/djust_components.py(_get_field_type,_infer_columns,_queryset_to_rowsfrom_forms) made explicit via__all__(closespy/unused-import#2171). Deleted 3 JS unused-variable declarations:decoderincomponents/static/djust_components/ttyd/ttyd_terminal.js:35,resolvedModeintheming/static/djust_theming/js/theme.js:416, andgetCookie()intheming/static/djust_theming/js/theme.js:449. Dismissed 2py/unused-global-variablefalse positives (#2104, #2105 —_psycopg/_psycopg_sqlindb/notifications.pyare lazy module-level caches assigned viaglobalinside_ensure_psycopg(); CodeQL's scope analyzer doesn't track global-write patterns). 4 note-levelpy/cyclic-importalerts (#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: F401preservation; replaced side-effect submodule imports withimportlib.import_module), removed ~30 unused local variables acrossrust_handlers.py,templatetags/djust_components.py,components/*.py, andtemplatetags/_forms.py/_advanced.py, removed ~4 unused module-level names (default_app_configincomponents/__init__.py,theming/__init__.py,admin_ext/__init__.py— obsolete since Django 3.2 auto-discovery), simplified 3lambda vals: f(vals)wrappers inAGG_FUNCS(pivot-table aggregations) to baresum/len, deduped 2import json/import asynciooccurrences infunction_component.py/mixins/data_table.py/db/notifications.py, reconciledimport X+from X import Yconflicts ingallery/registry.pyandtemplatetags/djust_components.py, and removed ineffectual single-...statements in Protocol / abstract method bodies inapi/auth.pyandtenants/audit.py. No behavior change; full suite passes (3428). Plus 3 dismissed with justification: 2 ×py/catch-base-exceptioninasync_work.py(existing# noqa: BLE001comments + documented design intent of surfacing every failure viaAsyncResult.errored), and 1 ×js/syntax-errorontheming/templates/.../theme_head.html(CodeQL's JS analyzer erroneously parsing a Django template as JavaScript). -
Break
themes → _base → presets/theme_packscyclic import (873 CodeQL alerts) + add explicitevent.origincheck to service workermessagehandler — CodeQL'spy/unsafe-cyclic-importrule flagged 872 alerts across the theming subsystem:themes/_base.pyimported dataclasses + shared style instances from..presetsand..theme_packs, and those two modules re-imported each theme file under.themes.*at module load — a real cycle that happened to work only becauseColorScale/ThemeTokens/ etc. were defined earlier inpresets.pythan 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) andpython/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.pynow imports from those two modules, bypassing the cycle;presets.pyandtheme_packs.pyimport 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 twoInteractionStyleclass definitions (the narrow DS-levelInteractionStyleattheme_packs.py:150was silently shadowed by the wider pack-level one at:1374— allINTERACT_*module-level instances relied on fields only the wider class had; unified on the superset definition in_types.py) and theINTERACT_MINIMAL/INTERACT_PLAYFULname 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-workermessagehandler inpython/djust/static/djust/service-worker.jswith an explicitevent.origin !== self.location.originearly return at the top of the listener, satisfying CodeQL'sjs/missing-origin-checkrule (alert #2170 — follow-up to the source+scope check shipped in #925). 7 regression cases inpython/djust/tests/test_theming_imports_backcompat.pycover: presets/theme_packs type exports still importable, shared instance exports still importable,_basere-exports identical object identity topresets/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-packInteractionStyledistinction forminimal/playfulis preserved (DSlink_hover="underline", packbutton_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 hadisinstance(field.widget, template.library.InvalidTemplateLibrary if False else type(None)). Theif False else type(None)ternary always evaluated totype(None), making the first operand unreachable dead code (CodeQLpy/constant-conditional-expression). Dropped the dead branch; the isinstance check is nowisinstance(field.widget, type(None))with a comment explaining the intent. -
Close 21
py/undefined-exportCodeQL alerts —djust/auth/__init__.pyanddjust/tenants/__init__.pyuse 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 aTYPE_CHECKINGblock to each__init__.pywith eager import statements gated behindif TYPE_CHECKING:— the imports execute only under static analysis (mypy, CodeQL, IDEs), never at runtime. The lazy-import runtime behavior is unchanged. Newpython/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 rawExceptionrepr 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 vialogger.exception(...)and return a genericRender error — see server logsmessage at both thetype == "tag"template-render path and thetype == "class"render-callable path.python/djust/theming/build_themes.py(py/call-to-non-callable, 1 alert):BuildTimeGenerator.__init__assigned thegenerate_manifest: boolconstructor argument ontoself.generate_manifest, which shadowed the method of the same name atdef generate_manifest(self, generated_files). Callingself.generate_manifest(generated_files)at line 521 frombuild_all()would have raisedTypeError: 'bool' object is not callableon any invocation of the full build — the method was effectively unreachable. Renamed the attribute toself._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_htmlpassed an HTML+CSS string throughstr.format(**kwargs)where the embedded literal CSS braces (body { font-family: ... }) were being parsed by Python's format machinery as placeholder keys, raisingKeyError/ValueErrorat 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 inpython/djust/tests/test_codeql_bugfixes.pycover: 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 CSSfont-familytokens. (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-domalert (#1978, warning) —inlineFormatinpython/djust/components/static/djust_components/markdown-textarea.jsapplied regex-based markdown substitutions on raw user input and wrote the result into the preview pane viainnerHTML, 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'sdata-rawpayload later lands in another user's view (shared drafts, admin review screens, collaborative editors). Fix: callescapeHtml()at the top ofinlineFormat(before any regex transform — the markdown syntax chars*,_,`,[,],(,)are not in the escape set so the substitutions still match). Added_sanitizeUrl()that rewritesjavascript:,data:, andvbscript:URL schemes (case-insensitive, leading-whitespace tolerant) to#in link targets, closing the[click](javascript:alert(1))attack surface. 11 JSDOM regression cases intests/js/markdown_textarea_xss.test.jscover<script>/<img onerror>/<b>escaping in headings / paragraphs / lists, preserved**bold**/*italic*/`code`functionality,javascript:/data:/VBScript:URL rewriting, safehttps://and relative URLs preserved, and fenced-code-block escaping still works. (python/djust/components/static/djust_components/markdown-textarea.js) -
Service worker
postMessagesame-origin source check — closes 1 CodeQLjs/missing-origin-checkalert (#2106, warning) —python/djust/static/djust/service-worker.jsprocessed any incomingmessageevent without inspectingevent.source. Service workers are inherently same-origin (they cannot be loaded cross-origin, sopostMessagefrom 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 touchingevent.data— (1) reject messages whoseevent.sourceis missing or whoseevent.source.typeis not'window'(rejectsworker/sharedworkerclients we don't expect), (2) reject WindowClient sources whoseurldoesn't start withself.registration.scope. 4 new JSDOM regression cases intests/js/service_worker.test.js(newdescribeblock "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-filltype: 'window'+ a scope-validurlon 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.pySignupView.get_success_urlaccepted anynextPOST param and passed it straight toredirect(), so a crafted form post could bounce newly-authenticated users to an attacker-controlled host — fixed by validating with Django'surl_has_allowed_host_and_scheme()against the current request host (withrequire_https=self.request.is_secure()); off-site, protocol-relative (//evil.com), and scheme-different values all fall back tosettings.LOGIN_REDIRECT_URL.python/djust/admin_ext/views.py:admin_login_requiredinterpolatedrequest.pathdirectly into the login-redirect query string (?next=<path>), letting a path containing&/#/ encoded control chars smuggle extra query params into the redirect — fixed withurllib.parse.urlencode({"next": request.path}).python/djust/theming/gallery/storybook.py:get_component_template_sourcejoined an HTTP-accessiblecomponent_nameURL 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-loggingalerts trace taint fromMEDICAL_THEME/LEGAL_THEMEconstant imports intheming/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 inpython/djust/tests/test_security_redirects_paths.pycover 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-exposurealerts — 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 vialogger.exception()instead of echoingstr(e)/type(e).__name__: {e}back in the JSON response body.python/djust/theming/inspector.py(3 sites attheme_inspector_apiGET/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 atreset_view_statemount failure,eval_handlerinvalid-JSON body,eval_handlerTypeError,eval_handlercatch-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— theserialize_errorpath'sstr(exc)dropped in favor of the same generic message the sibling"handler_error"/ catch-all"serialize_error"branches already use. Addedlogger = logging.getLogger(__name__)to the two files that lacked one. 3 regression cases inpython/djust/tests/test_stack_trace_exposure.pyverify the sentinel exception message is not reflected in the response body. Two alerts onpython/djust/components/gallery/views.py:726,762share 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-xssalerts (error severity) — Three real reflective-XSS sites inpython/djust/theming/gallery/views.py(lines 276, 281, 306):storybook_detail_viewandstorybook_category_viewechoed the user-controlled URL kwargscomponent_name/categoryintoHttpResponseNotFound(f"Unknown ...: {value}")withContent-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 withdjango.utils.html.escape(). Three defense-in-depth sites inpython/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. Addedescape()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 inpython/djust/tests/test_gallery_xss.pycover 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-injectionalerts — Addeddjust._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 theirrepr). Applied at 5 call sites inpython/djust/api/dispatch.py(wrappingview_slug,handler_name) andpython/djust/theming/gallery/component_registry.py(wrappingcomponent_name,str(exc)) — the sites where HTTP request data flows intologger.exception/logger.debugcalls. Format strings unchanged; djust already uses%s-style lazy logging per CLAUDE.md. 8 unit tests inpython/djust/tests/test_log_sanitization.py. No behavior change for non-malicious input. -
Refresh
uv.lockto 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; tightenedpyproject.tomlceiling to<6to 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 inset_key). Full Python test suite passes (3428 cases); full JS suite passes (1264 cases). No app code or test changes; lockfile +pyproject.tomlDjango ceiling only. Also catchesCargo.lockup to the v0.5.5rc1 crate versions (stale at 0.5.3rc1 on origin/main).
Changed
- Drop
blackdev dependency;ruff formatis now the canonical formatter — Pre-commit config has usedruff+ruff-formathooks since v0.5.x; noMakefile/ CI / import site references black. Removedblack>=24.10.0/black>=26.3.1from thedevgroup inpyproject.tomland the[tool.black]config section. Ruff already has matchingline-length = 100andtarget-version = "py39". Permanently closes the DependabotblackCVE 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 theUploadMixinfix shipped in #891. Both issues share the same root cause: a mixin'smount()-called method has a process-wide side effect beyond setting instance attrs, and the WS consumer's state-restoration path (which skipsmount()) never re-issues the side effect. #893 (Presence):track_presence()callsPresenceManager.join_presence(...)as a per-process singleton registration; after restore, the restored user's presence is invisible to other users andhandle_presence_joindoesn't fire for the user's own join. #894 (Notifications):listen(channel)callsPostgresNotifyListener.instance().ensure_listening(channel)which issues the PostgresLISTEN channelSQL 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()replaysjoin_presencewhen_presence_tracked=True;NotificationMixin._restore_listen_channels()replaysensure_listeningper channel (both convergent under replay —PresenceManager.join_presenceoverwrites the existing record with identical data so repeated calls are a no-op in effect;ensure_listeningexplicitly 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 intests/unit/test_mixin_restoration_893_894.pycover 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 usingUploadMixinwith the default pre-rendered HTTP→WS flow. The WS consumer's state-restoration path (websocket.py:1540-1572) skipsmount()when pre-rendered session state exists, and the liveUploadManagerinstance isn't JSON-serializable — so_upload_managersilently dropped by_get_private_state(), never restored, and any upload request hit_handle_upload_registerwith"No uploads configured for this view". Fix:allow_upload()now also records each call as a JSON-serializable dict inself._upload_configs_saved(list of kwarg dicts with primitive values); the newUploadMixin._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 intests/unit/test_upload_restoration_889.pycover: 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) — PhoenixJS.transitionparity. Three-phase class orchestration so template authors can drive CSS transitions without writing adj-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.transitionendremoves the active class (phase 3 stays as the final-state). 600 ms fallback timeout covers thedisplay: none/ zero-duration corner cases wheretransitionendnever fires. Any attribute-value change re-runs the sequence so authors can retrigger from JS. Newstatic/djust/src/41-dj-transition.js(~120 LOC); document-level MutationObserver matches thedj-dialog/dj-mutation/dj-sticky-scrollregistration pattern. 7 JSDOM cases intests/js/dj_transition.test.jscover 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-hookbookkeeping, 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: newLayoutMixininpython/djust/mixins/layout.pycomposed into theLiveViewbase, queuing at most one pending path (last-write-wins). WebSocket consumer: new_flush_pending_layout()wired at all nine_flush_page_metadatacall sites; renders the layout template with the view's currentget_context_data()and emits a{"type": "layout", "path": ..., "html": ...}frame. Graceful degradation:TemplateDoesNotExistor any render exception logs a warning and leaves the WS intact. Client side: newstatic/djust/src/40-dj-layout.jsmodule registered for thelayoutWS frame — finds the[dj-root]/[data-djust-root]inside the incoming HTML, splices in the live root node, swapsdocument.body, and fires adjust:layout-changedCustomEvent ondocument. Handles missing-root payloads and empty HTML gracefully. Tests: 12 Python cases intests/unit/test_layout_switching.py(mixin, consumer emit/noop/missing-template/no-mixin/view-none, LiveView composition) + 6 JSDOM cases intests/js/dj_layout.test.js(root-identity preservation, CustomEvent dispatch, malformed-payload refusal, empty-html noop,[dj-root]fallback, global export). Full user guide atdocs/website/guides/layouts.md(linked from_config.yamlandindex.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 negotiatepermessage-deflatewith 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. Newwebsocket_compressionconfig key (defaultTrue) exposed viadjust.config.config, bridged from a top-levelsettings.DJUST_WS_COMPRESSIONfor discoverability, and surfaced to the injected client bootstrap aswindow.DJUST_WS_COMPRESSION(application code can branch on it to skip manualJSON.stringifyoptimizations that only help without wire-level compression). 6 tests intests/unit/test_ws_compression_config.pycover 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 boilerplatedj-hooks every production app tends to write.dj-mutation(newstatic/djust/src/37-dj-mutation.js, ~100 LOC) fires adj-mutation-fireCustomEvent when the marked element's attributes or children change via MutationObserver, withdj-mutation-attr="class,style"for targeted attribute filters anddj-mutation-debounce="N"for burst coalescing (default 150 ms).dj-sticky-scroll(new38-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(new39-dj-track-static.js, ~90 LOC; Phoenixphx-track-staticparity) snapshots tracked<script src>/<link href>values on page load and, on every subsequentdjust:ws-reconnectedevent, diffs against the snapshot — dispatchesdj:stale-assetsCustomEvent on changed URLs, or callswindow.location.reload()when the changed element carrieddj-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 in03-websocket.js:onopennow dispatchesdocument.dispatchEvent(new CustomEvent('djust:ws-reconnected'))on every reconnect so application code (not justdj-track-static) can hook reconnects without touching internal WS state. Convenience Django template tag{% djust_track_static %}inlive_tags.pyemits the bare attribute for discoverability. All three attributes live-register via a document-level MutationObserver root (same pattern asdj-dialog) so VDOM morphs that inject or remove the marker re-wire observers automatically. 15 JSDOM test cases acrosstests/js/dj_mutation.test.js,tests/js/dj_sticky_scroll.test.js,tests/js/dj_track_static.test.js; 4 Python test cases intests/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 thepost_save/post_deletereceivers from a@notify_on_save-decorated model was to clear the entiresignals.receiverslist, which scorched unrelated test fixtures.untrack()now disconnects exactly the two receivers stashed onmodel._djust_notify_receiversand wipes the introspection attributes (_djust_notify_channel,_djust_notify_receivers) so a re-decoration goes through cleanly with a fresh channel. ReturnsTrueon success,Falseon a never-decorated model — idempotent, safe to call twice. Primarily for pytest teardowns in projects that decorate models at class-definition time. 5 tests intests/unit/test_db_notifications.py::TestUntrack. Exported fromdjust.dband documented in thedjust.dbmodule docstring. (python/djust/db/decorators.py,python/djust/db/__init__.py)See
docs/website/guides/database-notifications.md. -
Pre-minified
client.jsdistribution (v0.6.0 P1) — Production now servesclient.min.js(terser-minified) instead of the 35-module readable concat, with.gzand.brpre-compressed siblings built alongside it for whitenoise / nginx static serving. Measured impact:client.js410 KB →client.min.js146 KB raw → 39 KB gzip → 33 KB brotli (~92% reduction wire-size over the raw file).DEBUG=Truecontinues to serve the readableclient.jsso stack traces point at meaningful line numbers and contributors can poke at source directly. An explicitDJUST_CLIENT_JS_MINIFIEDsetting (bool) overrides theDEBUGheuristic 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.shgained aminify_and_compresshelper that runs terser (fromnode_modules/.bin/terseror PATH), then gzip-9and brotli-q 11; the step is skipped gracefully when terser isn't installed so contributors can still iterate on raw sources withoutnpm install. Source-maps (.min.js.map) are emitted for production-side debugging.djust.C012system check now recognizes bothclient.jsandclient.min.jsin manual-loading detection. 6 tests intests/unit/test_client_minified.pycover 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_handlerdocstring (crates/djust_templates/src/registry.rs) and the Python-side.pyistub (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 throughcontextinstead. 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_asyncconcurrent same-name cancellation semantics (#793) — Two rapidassign_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, itssetattr(self, "metrics", AsyncResult.succeeded(stale))clobbered the freshAsyncResult.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 intests/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 tracksfallbackas a template dependency alongsidevalue. 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 onlydynamicchanged — 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_specsnow preserves surrounding quotes on literal args so the extractor can distinguish literals from identifiers, and render-time filter application strips quotes via the newstrip_filter_arg_quoteshelper. No change to filter runtime semantics. 15 regression cases intests/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 usesContext::resolve(which walks getattr through the raw-PyObject sidecar) with a fallback toContext::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_notifypayload size guard (#810) — PostgreSQL capsNOTIFYpayloads 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 existingreset_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_notifyrender-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 thatgetattr(view, '_listen_channels', None)+ truthy gate handles both absent-attr and empty-set paths. (tests/unit/test_db_notifications.py) -
stream()withlimit=Npre-trims emitted inserts (#799) — Server trimsitems_listto at-mostlimitbefore emitting inserts. (python/djust/mixins/streams.py) -
teardownVirtualListrestores 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.childrenfilter redundancy removed (#801) — Cosmetic cleanup. (python/djust/static/djust/src/17-streaming.js) -
LiveViewTestClient.render_async()invokeshandle_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) — RaisesAssertionErrorwith 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()afterclose()raises (#823) —_finalizedflag now actively enforced; repeatedclose()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 atpython/djust/static/djust/src/12-vdom-patch.js:746-758previously stripped and overwrote attributes without consultingdjust.isIgnoredAttr. Attributes listed indj-ignore-attrswould survive individualSetAttrpatches (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 intests/js/ignore_attrs.test.jscover remove-loop and set-loop preservation. (python/djust/static/djust/src/12-vdom-patch.js)
Changed
dj-ignore-attrsCSV empty-token hardening (#816) —isIgnoredAttrnow 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 intests/js/ignore_attrs.test.jscover 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 andas <name>suffixes are correctly ignored), and thewith x=expr(andcount 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-classself.foo = ...assigns (#851) —_extract_context_keys_from_astnow iteratescls.__mro__(skippingdjust.*,djust_*,django.*,rest_framework.*, andbuiltins), so a child view that relies on attributes set in a parentmount()no longer produces spurious "unresolved" reports. The filter drops Django'sView/ 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_classare now a single source of truth in the newdjust.management._introspectmodule;djust_auditanddjust_typecheckboth import from it. No behavior change; purely a refactor to prevent drift as the set of management commands grows._introspect.walk_subclassesalso 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) —
- #828 —
DjustMainOnlyMiddlewarenow early-returns on responses withstatus_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+xmlin addition totext/html. Charset and boundary suffixes (text/html; charset=utf-8; boundary=xyz) are stripped before matching. Defensive test confirmsapplication/rss+xmlis still treated as non-HTML. - #829 —
djust.registerServiceWorker()is now idempotent. A second call returns the cached registration promise without re-runninginitInstantShell/initReconnectionBridge, so drain listeners and the WSsendMessagepatch 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-shellinnerHTMLswap at the top of33-sw-registration.js. The doc block was also corrected:dj-click/dj-submit/etc. work through document-level event delegation (not MutationObserver), anddj-hooknow explicitly re-runs via adjust.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 intests/js/service_worker.test.js(12 total). (python/djust/middleware.py,python/djust/static/djust/src/33-sw-registration.js) - #828 —
[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'sapi_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 asyncapi_response()are both awaited.serialize=withoutexpose_api=TrueraisesTypeErrorat decoration. Missing method or serializer exception → 500serialize_error(details logged server-side only);PermissionDeniedraised from either path surfaces as 403 (not 500). Theself._api_request = Trueflag is set by dispatch beforemount()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 inpython/djust/tests/test_api_response.pycover 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 indocs/website/guides/http-api.mdunder "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
LiveViewTestClientfor 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)assertlive_patch/live_redirectcalls;render_async()drains pendingstart_async/assign_asynctasks 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 ahandle_infomessage so pubsub / pg_notify handlers can be tested without real backend wiring. Full user-facing guide atdocs/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>withdj-dialog="open"to callshowModal()(backdrop, focus-trap, and Escape handling all browser-native); setdj-dialog="close"to callclose(). A document-levelMutationObserverwatches for attribute changes and DOM insertions so VDOM morphs that swapdj-dialogwork 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 inpython/djust/static/djust/src/35-dj-dialog.js. 8 JSDOM tests intests/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),@propertymethods, literal-dict keys returned fromget_context_data, template-local bindings ({% for %}/{% with %}/{% inputs_for as %}), framework built-ins (user,request,csrf_token,forloop,djust, etc.), and anything listed insettings.DJUST_TEMPLATE_GLOBALS. Silencing: per-template pragma ({# djust_typecheck: noqa name1, name2 #}), per-viewstrict_context = Trueopt-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 inpython/djust/tests/test_djust_typecheck.py. Full guide atdocs/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 onwindow.DEBUG_MODE— production builds render nothing (Django also stripstraceback/debug_detail/hintfrom the error frame in non-DEBUG mode, so there's nothing to leak). Exposeswindow.djustErrorOverlay.show(detail)/.dismiss()for manual invocation from devtools. 10 JSDOM tests intests/js/error_overlay.test.js. Full guide atdocs/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 anyBaseFormSetand 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=...)andremove_row(cls, row_prefix, data=..., prefix=...)handle management-form bookkeeping —add_rowbumpsTOTAL_FORMS(capped atmax_numwhen set,absolute_maxotherwise) and preserves existing row data;remove_rowwrites the standardDELETE=onflag soformset.deleted_formspicks it up onsave().FormSetHelpersMixinwires pre-bakedadd_row/remove_rowevent handlers to aformset_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 ifmount()forgets to initializeself._formset_data. 16 tests inpython/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.theming—djust_theming/static/djust_theming/css/scaffold.cssgains ~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 fulldata-layoutswitching 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;.containermax-width now readsvar(--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 testbaseline went from2135 passed, 61 failed, 21 errors(which had blocked normal merges for the entire v0.5.1 milestone and forced--adminon every PR) to2219 passed, 0 failed, 0 errors. Four fix clusters:- Test-infrastructure shims (64 fixes) — added
tests/gallery_test_urls.pyandtests/test_critical_css.pyURL-conf shims that theming tests reference via@override_settings(ROOT_URLCONF=...)but were never created; addedmcp[cli]>=1.2.0; python_version >= '3.10'to dev deps sodjust.mcpserver tests stop throwingModuleNotFoundError. - Stale
@layertest expectations (4 fixes) — several theming CSS files (components.css,layouts.css,pages.css, critical-CSS generator) were intentionally unwrapped from@layerblocks 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_deeppreset's internalnamewas"ocean"while its registry key was"ocean_deep"; one straytext-align: leftincomponents.css.tp-select-optionbroke RTL support (changed totext-align: start); and the CSS prefix generator's hand-maintained_COMPONENT_CLASSESlist had drifted fromcomponents.css—.btn-edit,.btn-remove,.avatar,.breadcrumb,.dropdownand many more weren't being prefixed when a customcss_prefixwas 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_renderencoded a wrong assumption about_snapshot_assigns(identity-based by design); rewrote to match the documented contract.
- Test-infrastructure shims (64 fixes) — added
- CSS prefix generator hardening — Auto-extraction regex gained a negative lookbehind
(?<![\w])to prevent capturing domain fragments inside data-URIs (previously.organd.w3inhttp://www.w3.org/2000/svgwere mis-captured as class selectors, producinghttp://www.dj-w3.dj-org/2000/svgunder prefix); compound state-class chains like.wizard-step.completednow 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-lazylazy 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_fornested 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 callsself.trigger_submit("#form-id")after validation passes; the client receives the push event, verifies the target form carriesdj-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 thesearchevent is in-flight. Previously required combiningdj-loading.show+dj-loading.for="event_name"with an inlinestyle="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.showtrigger. Coexists with the existingdj-loading.*modifier family. (python/djust/static/djust/src/10-loading-states.js)
Tests: 11 JS test cases in
tests/js/form_polish.test.jscovering every happy path and failure mode; 4 Python tests inpython/djust/tests/test_trigger_submit.pylocking in the push-event shape. Client.js: 35 → 36 source modules (+~120 LOC JS, +~30 LOC Python). Scoped scoped-loadingdj-loading="event"implementation reuses existingglobalLoadingManagerinfrastructure — 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")—@computednow 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_assignssemantics). Plain@computed(no args) retains property semantics — recomputes every access. ReactuseMemoequivalent. (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 aftermount().changed_fieldsreturns a set of attr names that differ from the baseline;is_dirtyisbool(changed_fields);mark_clean()resets the baseline (call after a successful save). Use cases: "unsaved changes" warnings (beforeunload), conditional save buttons, optimizedhandle_eventthat skips work when nothing changed. Respectsstatic_assignsand 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 19useIdequivalent. Returns a deterministic per-view ID stable across renders of the same logical position. Useful foraria-labelledby, form field IDs, and any element that needs a consistent identifier across re-renders. Format:djust-<viewslug>-<n>[-<suffix>]. Counter resets viareset_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 underkey; descendants look it up withconsume_context, walking the_djust_context_parentchain. Scoped per render tree;clear_context_providers()resets. (python/djust/live_view.py) Seedocs/website/guides/state-primitives.md.
- Memoized
-
Auto-generated HTTP API from
@event_handler(v0.5.1 P1 HEADLINE, ADR-008) — Opt-in@event_handler(expose_api=True)exposes a handler atPOST /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, reusingvalidate_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 packagedjust.apiwithDjustAPIDispatchView(dispatch view),api_patterns()(URL factory),OpenAPISchemaView(schema endpoint),SessionAuth+ pluggableBaseAuthprotocol (auth classes may opt out of CSRF viacsrf_exempt = True), and a registry that walksLiveViewsubclasses with exposed handlers.LiveViewgains two read-only contract attributes:api_name(stable URL slug) andapi_auth_classes(auth class list). Response shape mirrors the WS assigns-diff:{"result": <return>, "assigns": {<changed public attrs>}}. Error shapes are structured witherror/message/details— 400 validation, 401 unauth, 403 denied or CSRF fail, 404 unknown view/handler or handler notexpose_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_limitsettings; WebSocket continues to use its per-connectionConnectionRateLimiter. 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_auditnow lists everyexpose_api=Truehandler 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 atdocs/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 withX-Djust-Main-Only: 1and swaps in the fresh<main>contents. Shell/main split uses a single non-greedy regex — nested<main>inside HTML comments or</main>insideCDATAare documented limitations (full HTML parser deferred). Server side honors the header via the newdjust.middleware.DjustMainOnlyMiddleware, which extracts the first<main>…</main>inner HTML, updatesContent-Length, and stampsX-Djust-Main-Only-Response: 1. The middleware only touches HTML responses; JSON / binary / streaming responses pass through unchanged. Ordering-safe — it can sit anywhere inMIDDLEWAREthat sees the rendered response. - WebSocket reconnection bridge. Client-side wraps
LiveViewWebSocket.sendMessageso that whenws.readyState !== OPENthe serialized payload is posted to the SW viapostMessage({type: 'DJUST_BUFFER', connectionId, payload})instead of being dropped. The SW stores messages in an in-memoryMapkeyed by connection id, capped at 50 per connection (oldest dropped). On reconnect the client firesDJUST_DRAIN; the SW returns the buffered payloads and the client replays each viaws.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 intoclient.js),python/djust/static/djust/src/33-sw-registration.js(new, concatenated intoclient.js),python/djust/middleware.py(new),python/djust/config.py(newservice_workerdefaults sub-dict), tests intests/js/service_worker.test.js(10 cases) andtests/unit/test_main_only_middleware.py(7 cases), full guide atdocs/website/guides/service-worker.md.
- Instant page shell. The SW caches the first navigation's response split into a "shell" (everything outside
-
UploadWriter— raw upload byte-stream access for direct-to-S3 / GCS streaming (Phoenix 1.0 parity, v0.5.0 P2) — NewUploadWriterbase class indjust.uploadswith anopen()→write_chunk(bytes)→close() -> Any/abort(error)lifecycle, wired intoallow_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, zeroentry._chunks. Writers are instantiated lazily per upload on the first chunk (so abandoned uploads never open an S3 multipart upload), opened exactly once, fedwrite_chunk()per client frame, and finalized viaclose()whose return value is stored onUploadEntry.writer_resultand 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 viaUploadManager.cleanup()) routes throughabort(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. IncludesBufferedUploadWriterhelper that accumulates client-sent 64 KB chunks until a configurablebuffer_threshold(default 5 MB — S3 MPU minimum part size except for the last) and callson_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 indocs/website/guides/uploads.mdwith 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.1JS.ignore_attributes/1parity, v0.5.0 P2) — Mark specific HTML attributes as client-owned so VDOMSetAttrpatches skip them.<dialog dj-ignore-attrs="open">prevents the server from resetting theopenattribute 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 insideapplySinglePatch'scase 'SetAttr'after theUNSAFE_KEYScheck; the attribute write is skipped entirely (andbreaks out of the case) when the element opts out.RemoveAttris 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.1ColocatedHookparity, 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 walksscript[type="djust/hook"]elements on init and after each VDOM morph (reinitAfterDOMUpdate), registers each body aswindow.djust.hooks[name]vianew Function, and marks the script withdata-djust-hook-registered="1"so re-scans are idempotent. Optional namespacing viaDJUST_CONFIG = {"hook_namespacing": "strict"}prefixesdata-hookwith<view_module>.<view_qualname>so two views can each defineChartwithout 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'srender()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 Djangopost_save/post_deleteand emitsNOTIFY <channel>, <json>;self.listen("orders")inmount()subscribes the view (joins a Channels group nameddjust_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-widePostgresNotifyListenerowns one dedicatedpsycopg.AsyncConnection(outside Django's pool — long-lived LISTEN connections don't play nice with pgbouncer transaction pooling) and runsasync for notify in conn.notifies():, bridging every NOTIFY intochannel_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()raisesDatabaseNotificationNotSupportedwhen 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 WSmount()re-fetch handles the client-side recovery case. Documented indocs/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
getattrfallback 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 Pythongetattrwhenuseris 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 newRustLiveView.set_raw_py_values()method; Rust'sContext::resolve()tries the normal value-stack path first, then walksgetattron attached PyObjects one segment at a time.PyAttributeError(and any property-descriptor exceptions) are caught — missing attrs render as empty, matching Django'sTEMPLATE_STRING_IF_INVALIDdefault.ValuestaysSerialize-friendly (noValue::PyObjectvariant); sidecar lives outside the Value enum viaArc<HashMap<String, PyObject>>onContext. (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 complementingregister_tag_handler(emits HTML) andregister_block_tag_handler(wraps content). An assign tag'srender(args, context)method returns adict[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 viadjust._rust.register_assign_tag_handler(name, handler). NewNode::AssignTagvariant; 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) Seedocs/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 viatransform: translateY(...)on an inner shell plus a hidden spacer for scrollbar length, scroll handler batched throughrequestAnimationFrame, real element identity preserved across scrolls for hook/framework compatibility. Integrates with the VDOM morph pipeline: new containers are picked up byreinitAfterDOMUpdate, anddjust.refreshVirtualList(el)is available for explicit repaints.djust.teardownVirtualList(el)disconnects observers for unmounted containers. (python/djust/static/djust/src/29-virtual-list.js) Seedocs/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 viaIntersectionObserver.<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 adata-dj-viewport-firedsentinel; calldjust.resetViewport(container)or replace the sentinel child to re-arm. New server-sidestream()limit=Nkwarg andstream_prune(name, limit, edge)method emit astream_pruneop 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'sassign_async. Callself.assign_async("metrics", self._load_metrics)inmount()(or any event handler); the attribute is set toAsyncResult.pending()immediately, the loader runs via the existingstart_asyncinfrastructure, and on completion the attribute becomesAsyncResult.succeeded(result)orAsyncResult.errored(exc). Templates read the three mutually-exclusive states via{% if metrics.loading %}…,{% if metrics.ok %}{{ metrics.result }}…,{% if metrics.failed %}{{ metrics.error }}…. Sync andasync defloaders are both supported; multiple calls in the same handler load concurrently. Cancellation piggybacks oncancel_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 toassign_async: wrap a section depending on one or moreAsyncResultassigns, and the boundary emits a fallback while any are loading, an error div if any failed, or the body once all areok. Explicitawait="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) Seedocs/website/guides/loading-states.md. -
Function components via
@componentdecorator (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 fullLiveComponentclasses 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)andSlot("col", multiple=True)DSL, declared on aLiveComponentclass attribute (assigns = [...]/slots = [...]) or on function components via@component(assigns=[...], slots=[...]). Validation runs at mount/invoke: required-missing raisesAssignValidationErrorin DEBUG and warns in production, type coercion (str → int / bool / float) is automatic, enum violations viavalues=raise. Child-classassignsextend (and override by name) parent declarations via MRO walk. (python/djust/components/assigns.py,python/djust/components/base.py) Seedocs/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 asassigns["slots"] = {name: [{"attrs": {...}, "content": "..."}, ...]}. Non-slot content in the{% call %}body becomeschildren/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"→"and'→'(in addition to&/</>). Detection reuses the existingis_inside_html_tag_at()parser helper — the per-Node::Variablein_attrflag is computed at parse time, so renderer cost is a bool check.|safestill bypasses escaping in both attribute and text contexts. Today's behaviour is unchanged (the basehtml_escapealready 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 }}">whenurlcontains 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_nodeshad no arm forNode::InlineIf, so itstrue_expr/condition/false_exprvariables were silently dropped from the dep set of any surrounding{% if %}/{% for %}/{% with %}. Changing the condition alone (e.g.step_activein{% for s in steps %}<span class="{{ 'active' if step_active else 'idle' }}">) producedpatches=[]and stale HTML. Fix:extract_from_nodesnow extracts non-literal variables from all threeInlineIfexpressions. - 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 %}, becauseextract_from_nodestreatedIncludeas 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 intersectchanged_keys({field_html}),needs_renderreturnedfalse, the cached HTML was reused, and the text-region fast-path compared byte-identical old/new HTML →patches=[]withdiff_ms: 0. Manifested with deeply-nestedWizardMixintemplates ({% extends %} → {% block %} → {% if current_step_name == "..." %} → {% include "step_*.html" %}). Fix:extract_from_nodesnow injects"*"into the variables map when it encounters a nestedIncludeorCustomTag/BlockCustomTagduring its walk, so wrapper deps include the wildcard and those nodes are always re-rendered. (crates/djust_templates/src/parser.rs) _force_full_htmlnow callsset_changed_keysso Rust partial renderer re-renders (#783) — When_force_full_htmlwas set,_sync_state_to_rust()clearedprev_refsto force all context to Rust, but theset_changed_keyscall was gated byif prev_refswhich evaluated to False after clearing. Rust's partial renderer saw nochanged_keys, fell back to full render with emptychanged_indices, and the text-region fast-path compared identical old/new HTML → zero patches. Fix:set_changed_keysis now called when_force_full_htmlis set regardless ofprev_refs.
Docs
- ROADMAP correction:
temporary_assignsis already implemented — The v0.5.0 ROADMAP entry claimingtemporary_assignswas "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_assigns—tests/unit/test_temporary_assigns.pycovers 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/AsyncResult—tests/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 withcancel_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-AsyncResultrefs defaulting to loading, default spinner, Django template fallback, template-error graceful degradation, nesting, and whitespace-tolerant comma-separated lists. - Regression suite for
|safeHTML blob diff (#783) —tests/test_rust_vdom_safe_diff_783.pyexercises the WizardMixin-style pattern wherefield_htmlis derived inget_context_data()from an instance attribute. Covers dict reassignment, in-place nested mutation, the_force_full_htmlcodepath, 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 nestedInclude/CustomTagshapes. - Node variant exhaustiveness check —
sample_for_coverageexhaustive match onNode::*+sample_nodes()constructor +NO_VARS_VARIANTSallow-list. Any newNodevariant 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) —
TestPartialRenderCorrectnessintests/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.
- Rust unit tests for
- New PyO3 method
DjustLiveView.clear_fragment_cache(test-only) (crates/djust_live/src/lib.rs) — clearsnode_html_cache,last_html,fragment_text_map,text_node_indexwhile preservinglast_vdomso 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
Bootstrap4Adapterfor projects using Bootstrap 4 (NYC Core Framework, government sites, legacy projects). SetDJUST_CONFIG = {"css_framework": "bootstrap4"}. Includes propercustom-select,custom-control-*classes for checkboxes/radios, andform-groupwrappers. Seedocs/website/guides/css-frameworks.md. -
Dedicated radio button classes — Radio buttons now use
radio_class,radio_label_class, andradio_wrapper_classconfig keys (with fallback to checkbox classes). Both Bootstrap 4 and 5 configs define radio-specific classes. -
Select widget class support —
ChoiceFieldwithSelectwidget usesselect_classconfig key (e.g.,custom-selectfor BS4,form-selectfor BS5) instead of the genericfield_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 likecurrent_step = wizard_steps[step_index]could be missed when the handler only changedstep_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 djuststays lean;pip install djust[all]gets everything.- Phase 1+2:
djust-auth+djust-tenants→ core (#770) —djust-auth(879 LOC) merged intopython/djust/auth/package with lazy imports.djust-tenantsmissing modules (audit, middleware, managers, models, security) merged into existingpython/djust/tenants/. Both are core — no extras needed. 27 new tests. - Phase 3:
djust-admin→djust[admin](#771) — 3,878 LOC merged intopython/djust/admin_ext/(avoids collision withdjango.contrib.admin). Views, forms, adapters, plugins, decorators, template tags, 7 HTML templates. 40 new tests. - Phase 4:
djust-theming→djust[theming](#772) — 49,105 LOC merged intopython/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-components→djust[components](#773) — ~100K LOC merged intopython/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.
- Phase 1+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). Requirespath("_djust/observability/", include("djust.observability.urls"))in the project urls.py. -
get_view_assigns— Real server-sideself.*state of the mounted LiveView for a given session. Complements browser-mcp's client-onlydjust_state_diffwith 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 fromhandle_exception(). Replaces "can you paste the terminal?" for 80% of blind-debugging cases. -
tail_server_log— Ring-buffered (500) Django/djust log records withsince_ms+levelfilters.djust.*captured at DEBUG+,django.*at WARNING+. Seedocs/website/guides/mcp-server.md. -
get_handler_timings— Per-handler rolling 100-sample distribution (min/max/avg/p50/p90/p99). Reuses existingtiming["handler"]measurements; no extra perf counters. -
get_sql_queries_since— Per-event SQL capture viaconnection.execute_wrappers. Queries are tagged with(session_id, event_id, handler_name)+stack_topfiltered to skip framework frames. -
reset_view_state— Replayview.mount()on a registered instance. Clears public attrs, re-invokesmount(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}. v2dry_run=Trueinstalls aDryRunContextthat blocksModel.save/delete,QuerySet.update/delete/bulk_create/bulk_update,send_mail/send_mass_mail,requests.*, andurllib.request.urlopen— first attempt raisesDryRunViolationand the response surfaces{blocked_side_effect}.dry_run_block=Falserecords 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 runtimefind_dead_bindings). Seedocs/guides/djust-audit.md. -
seed_fixtures(fixture_paths)in djust MCP — Subprocess wrapper aroundmanage.py loaddatafor 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_debugstate dump) to every connected session. Early-return whenhotreload=True AND patches==[]. Non-hot-reload empty patches still sent (loading-state clear ack needed).client.js: guard 38 unguardedconsole.logcalls (#761) — Perdjust/CLAUDE.mdrule, noconsole.logwithoutif (globalThis.djustDebug)guard. Introduced adjLoghelper in00-namespace.jsand replaced bareconsole.log→djLogacross 12 client modules.console.warn/console.erroruntouched (real problems stay visible in prod).- Observability
DryRunContext._uninstalllogs setattr failures (#759) — Silentexcept Exception: passmeant the process could run indefinitely with a wrappedModel.saveif uninstall partially failed — catastrophic for a dev server. Replaced with alogger.warningso 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_updateare patched alongsideModel.save/delete, so a handler that doesModel.objects.filter(...).update(...)correctly raisesDryRunViolationinstead 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.mockto 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 indjust_vdomthat uses html5ever'sparse_fragmentwith 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_nodesnow 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 carrytext, so an explicitis_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 viaOnceLock<ResolvedInheritance>on theTemplatestruct (shared viaTEMPLATE_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_assignsuses identity + shallow fingerprints (id, length, content hash for list-of-dicts) instead ofcopy.deepcopy. Framework-internal keys (csrf_token,kwargs,temporary_assigns,DATE_FORMAT,TIME_FORMAT) and auto-generated_countkeys are excluded fromset_changed_keysto 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_rustpreviously 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 inget_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 intest_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
morphElementandSetAttrpatches now clear.valuewhen thenameattribute changes. -
In-place dict mutation detection —
_snapshot_assignsnow fingerprints list contents (id + dict values hash) to detect mutations liketodo['completed'] = Truethat don't change the list's id or length. Falls back to id-only for unhashable values. -
Derived context value detection — When
_changed_keysis set, the sync also checks non-immutable context values by id() to catch derived values (e.g.,productsfrom_products_cache) that change via private attributes.
[0.4.4] - 2026-04-15
Changed
-
Remove double
updateHooks()/bindModelElements()scanning — These were called in bothapplyPatches()andreinitAfterDOMUpdate(), scanning the full DOM twice per patch cycle. Removed fromapplyPatches(). 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 stdlibjson.loads()when orjson is installed. Falls back gracefully. -
Gate debug payload behind panel open state —
get_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 sendsdebug_panel_open/debug_panel_closeWS 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 indj-rootto fail VDOM patching and fall back to full HTML recovery. -
Scroll to top on
dj-navigatelive_redirect —handleLiveRedirect()now scrolls to the top of the page (or to anchor if URL has a hash) afterpushState.
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 thedj-rootelement 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 viaget_render_timing()and propagated to WebSocket response performance metadata.
[0.4.3] - 2026-04-14
Fixed
-
{% csrf_token %}no longer renders poisonedCSRF_TOKEN_NOT_PROVIDEDplaceholder (#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 fromget_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()beforerender_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
|dateand|timefilters honor DjangoDATE_FORMAT/TIME_FORMATsettings (#713) — Newapply_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
|datefilter now works onDateFieldvalues (#719) — The|datefilter previously only parsed RFC 3339 datetime strings.DateFieldvalues (bare dates like "2026-03-15") are now parsed via aNaiveDatefallback 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: passin CSRF injection now logs a warning (#716) — The CSRF token injection in_sync_state_to_rust()previously swallowed all exceptions silently. Now logs viadjust.rust_bridgelogger withexc_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@contextmanagerthat guarantees cleanup of temporarily injected view attributes. Merged as PR #721 + #727. -
Pre-existing test fixes —
test_debug_state_sizescorrected forjson.dumps(default=str)behavior and\uXXXXescaping.navigation.test.jssuppresses happy-dom/undici WebSocket mockdispatchEventincompatibility.
Added
-
Python integration tests for DATE_FORMAT settings injection (#718) — 4 tests verifying
_sync_state_to_rustinjects DATE_FORMAT/TIME_FORMAT from Django settings. Merged as PR #721. -
Negative tests for
|datefilter 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 collectsid()s of all sub-objects reachable from changed instance attrs and includes any derived context var whoseid()appears in that set. Previously, context vars computed inget_context_data()that returned sub-objects of a mutated dict (e.g.,wizard_step_data.get("person", {})) were skipped because theirid()was unchanged, causing templates to render stale data. Depth-capped at 8 with cycle detection. 9 new regression tests. -
as_live_field()now respectswidget.input_typeoverride fortypeattribute (#683 re-open) — The initial #683 fix mergedwidget.attrsbuttypewas still ignored because Django movestype=fromattrsintowidget.input_typeduring widget__init__._get_field_type()now checkswidget.input_typeagainst the widget class's default and uses the override when they differ (e.g.TextInput(attrs={"type": "tel"})setsinput_type="tel"). 4 new regression tests coveringtype="tel",type="url",type="search", and the defaulttype="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 embeddedLiveComponent, not just when it fires on the view itself. Without this, aTutorialStep(wait_for="submit", ...)wheresubmitis a handler on a childFormComponentwould silently stall forever — the parent view's waiter would never resolve and the tour would hang. The fix is in the WebSocket consumer'shandle_eventcomponent-event branch: after the component handler runs, the consumer now callsself.view_instance._notify_waiters(event_name, notify_kwargs)with the handler's kwargs + an injectedcomponent_idkey, mirroring the notification that already happened in the main LiveView branch from Phase 1b. Thecomponent_idinjection means apps can use the waiter'spredicateargument 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 thedjust.websocketlogger 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 inpython/tests/test_waiter_component_propagation.pycovering: component event resolves parent waiter,component_idis 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_waitersis logged-and-swallowed rather than propagating.docs/website/guides/tutorials.mdLimitations section updated to document the new behavior with acomponent_idpredicate example.
Documentation
-
Tutorial bubble must be placed outside
dj-root(#699) — If the{% tutorial_bubble %}tag is placed inside thedj-rootcontainer, morphdom recovery (which replaces the entiredj-rootcontent 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 outsidedj-root. Thetutorial_bubbletemplate tag docstring is also updated with this requirement. -
data-*attribute naming convention documented in Events guide (#623) — Howdata-foo-baron an HTML element maps tofoo_barin 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, thedj-value-*alternative, which internaldata-*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 everymanage.pyinvocation and are noisy for projects that deliberately don't use the checked features (daphne, explicitdj-root, non-primitive mount state). A newsuppress_checksconfig key inDJUST_CONFIG(orLIVEVIEW_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-drafterv6 → v7 + droppull_requesttrigger — v7 validatestarget_commitishagainst the GitHub releases API and rejectsrefs/pull/<n>/mergerefs, which is whatgithub.refresolves to under apull_requesttrigger. v6 silently tolerated this; v7 does not, causing every PR to fail withValidation Failed: target_commitish invalid. The fix is to drop thepull_requesttrigger — release-drafter is designed to track changes that have landed on the release branch, not comment on in-flight PRs, sopush: 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-v84.0.18 → 4.1.4 (patches + new test runner features),jsdom29.0.1 → 29.0.2,happy-dom20.8.4 → 20.8.9. Full JS suite remains green (1111 tests). - Cargo:
tokio1.50 → 1.51 (workspace),uuid1.22 → 1.23,proptest1.10 → 1.11 (djust_vdom),indexmap2.13.0 → 2.14.0 (transitive pickup via cargo update).cargo check --workspaceclean;cargo test -p djust_vdompasses all 42 proptest-driven tests on the new 1.11 runtime. - GitHub Actions:
actions/github-scriptv8 → v9 (two workflows),astral-sh/setup-uvv6 → v7 (test workflow). Workflow syntax unchanged. - Intentionally deferred:
html5ever0.36 → 0.39 is a 3-minor-version jump that requires a matchingmarkup5ever_rcdom0.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 breakcargo publishand leak unreleased upstream state, so this stays deferred until upstream publishes.release-drafter/release-drafterv6 → v7 was also deferred out of this chore batch because of atarget_commitishvalidation 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).
- npm:
Fixed
-
@backgroundnatively supportsasync defhandlers (#697) — The@backgrounddecorator now detectsasyncio.iscoroutinefunctionand creates a native async closure so_run_async_workcanawaitit directly on the event loop instead of routing throughsync_to_async. The fragileinspect.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 viaself._ws_consumer._flush_push_eventsat call time instead of relying on a stored_push_events_flush_callbackthat 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_assignsdeep-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 = Truewhen 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 callsuper().__init__(), so writingclass MyView(LiveView, TutorialMixin)silently skips TutorialMixin's initialisation. A newdjust.V010system 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 viaDJUST_CONFIG = {"suppress_checks": ["V010"]}. 5 new tests. Tutorials guide updated with correct ordering. -
@background async defhandlers now execute correctly (#692) —@backgroundwraps handlers in a sync closure; when the handler isasync 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 intest_background_async.pyverify both sync and async handlers execute their bodies. -
push_commandsin@backgroundtasks now flush mid-execution (#693) — Push events queued bypush_commandsinside a@backgroundhandler only reached the client when the entire task completed. The_flush_pending_push_eventscallback mechanism (already on main) lets TutorialMixin and other background handlers flush events immediately. A new publicawait self.flush_push_events()method on PushEventMixin provides the same capability to any@backgroundhandler. 7 new tests intest_push_flush_background.py. -
get_context_datano longer includes non-serializable class attributes (#694) — The MRO walker inContextMixin.get_context_data()added class-level attributes (liketutorial_steps) to the template context. Non-JSON-serializable values were silently converted to theirstr()repr, corrupting state on subsequent events. The fix skips class-level attributes that fail a JSON serialisability probe. Additionally,TutorialMixinnow stores steps as_tutorial_steps(private) with a read-onlytutorial_stepsproperty, 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
viewBoxandpath din the debug toolbar were rendered garbled because the Rust VDOM'sto_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&or<to<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 withto_html()which re-encodes them). The fix adds anin_raw_textflag to the internal_to_html()serializer that propagates through<script>/<style>children, skippinghtml_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_dataPython types no longer serialize to null (#628) —datetime.date,datetime.datetime,datetime.time,Decimal, andUUIDvalues inform.cleaned_datastored in public view state are now properly serialized to their JSON representations (ISO strings, floats, strings) instead of silently becomingnull. Both theDjangoJSONEncoderandnormalize_django_value()already handled these types; 10 new regression tests confirm the behavior. -
set()is now JSON-serializable as public state (#626) — Storing a Pythonset()orfrozenset()in public view state no longer crashesjson.dumps. Sets are serialized as sorted lists (falling back to unsorted when elements aren't comparable). BothDjangoJSONEncoder.default()andnormalize_django_value()now handleset/frozenset. 11 new regression tests. -
dictstate no longer corrupted tolistafter Rust state sync (#612) — Round-tripping state through the Rust MessagePack serialization boundary could corruptdictvalues intolistbecause#[serde(untagged)]on theValueenum letrmp_serdematch a msgpack map against theListvariant before tryingObject. The fix replaces the derivedDeserializewith a custom visitor-based implementation that uses the deserializer's type hints (visit_mapvsvisit_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 mergeswidget.attrsinto rendered HTML (#683) — Theas_live_field()method (and{% live_field %}tag) dropped any attributes defined on a Django widget'sattrsdict —type="email",placeholder,pattern,min/max, customdata-*, and any other HTML attributes were silently lost. The fix adds_merge_widget_attrs()toBaseAdapter, called from_render_input,_render_checkbox, and_render_radio, which mergesfield.widget.attrsinto the output attributes with djust-specific keys (dj-change,name,class, etc.) taking precedence over widget defaults. BooleanFalse/Nonevalues in widget attrs are filtered out to avoid renderingdisabled="False". 17 new regression tests inpython/tests/test_live_field_widget_attrs.pycovering: 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(), andreplaceChild()on#textnodes, 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 anisElement(node)guard at the top of each of the five patch-type branches in12-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 intests/js/vdom_patch_errors.test.jscovering 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 theautofocusattribute on initial page load. The patcher now detectsautofocuson newly inserted elements after each patch cycle and calls.focus()explicitly. 4 new JS tests intests/js/vdom-autofocus.test.jscovering 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 ofget_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_statesession key) and_load_state_from_session()/ the reconnect path inRequestMixin._restore_session_state()(restores private attrs before the view resumes). 20 new regression tests inpython/tests/test_private_attr_preservation.pycovering: 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
reinitAfterDOMUpdateviarequestAnimationFrame(#619, fixes #618) — Carry-over bugfix from v0.4.1. When a page is pre-rendered via HTTP GET, the WebSocket mount used to callreinitAfterDOMUpdate()synchronously right after stampingdj-idattributes 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 +_mountReadyflag + form recovery + auto-recover) into arunPostMountclosure and schedules it viarequestAnimationFrame(runPostMount)when available, falling back to a synchronous call whenrequestAnimationFrameis 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 sodj-mountedhandlers and recovered form inputs still see bound event listeners. The non-prerendereddata.htmlinnerHTML-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 intests/js/mount-deferred-reinit.test.jsasserting: the rAF wrapper is present, the synchronous fallback is preserved, the closure is namedrunPostMountfor stable debugging,reinitAfterDOMUpdate()runs before_mountReadyinside the closure,_mountReadyis 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 ofreinitAfterDOMUpdate()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=Falseand generate.env.example(#637) — Carry-over bugfix from v0.4.1. Previously,python -m djust startproject mysiteandpython -m djust new mysiteboth generated asettings.pywithDEBUG = TrueandALLOWED_HOSTS = ["*"]as hardcoded literals. A developer who deployed the scaffolded output without remembering to flip those values ran production with full stack traces, thedjango-insecure-<random>default SECRET_KEY, and a wildcard host allowlist — the exact footgun that A001 (DEBUGenabled) and A014 (ALLOWED_HOSTStoo permissive) flag indjust_audit. Now both scaffold paths (cli.py'scmd_startprojectand the higher-leveldjust.scaffolding.generator.generate_project) emitDEBUG = os.environ.get("DEBUG", "False").lower() in ("true", "1", "yes")andALLOWED_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.exampletemplate alongside.gitignore(which already ignores.env) so local development picks up developer-friendly values viacp .env.example .env+ whatever.envloader the developer uses. The.env.exampleincludesDEBUG=True, a freshly-generatedSECRET_KEYtoken (viasecrets.token_urlsafe(50)), andALLOWED_HOSTS=localhost,127.0.0.1so the local experience hasn't changed. 4 new regression tests inpython/tests/test_cli_scaffold.pyasserting:DEBUG = Trueis no longer literal,DEBUGreads from env with"False"fallback,ALLOWED_HOSTS = ["*"]is no longer literal, narrowlocalhost,127.0.0.1env default,.env.exampleexists with the three documented vars and a real (not template-placeholder) secret key,.envremains in.gitignorewhile.env.exampledoes 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 ofTutorialStepdataclasses on aLiveViewthat mixes inTutorialMixin; the framework runs the state machine as a@backgroundtask, pushing a highlight + narrate + focus chain at each step's target viapush_commands(Phase 1a), then eitherasyncio.sleep'ing for auto-advance steps orawaitingwait_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.TutorialStepsupports per-steptarget(CSS selector, required),message(narration text),position(top/bottom/left/rightbubble hint),wait_for(handler name to suspend on),timeout(seconds — pairs withwait_forfor bounded waits or used alone for auto-advance),on_enter/on_exit(optional extraJSChainpushes for per-step setup/teardown beyond the default highlight + narrate + focus), andhighlight_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 viaasyncio.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 fortour:narrateCustomEvents atdocumentlevel (dispatched at the step's target withbubbles: true), positions itself next to the target per the step'spositionhint, displaysstep N / totalprogress, and includes "Skip" and "Close" buttons pre-bound to the mixin's event handlers — the default bubble is markeddj-update="ignore"so morphdom doesn't clobber it during VDOM patches. The new client-sidesrc/28-tutorial-bubble.jsmodule (~140 lines, bringsclient.jsto 30 modules) registers its listeners unconditionally at IIFE time, readsdetail.text/target/position/step/totalfrom 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_eventintegration (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_exitpushes, per-step highlight class override, and per-step narrate event override. 9 new Python tests for thetutorial_bubbletemplate tag covering defaults, customcss_class/event/position, invalid-position fallback to"bottom", skip+cancel button bindings, text/progress element classes, and XSS escaping of hostilecss_classandeventkwargs. 12 new JS tests intests/js/tutorial-bubble.test.jscovering listener registration, text content updates, progress text updates, show/hide viadata-visible, default/custom position application, missing-target graceful handling, missing-bubble graceful handling,tour:hideevent, and repeated updates on subsequent events. Zero new runtime dependencies — stdlibasyncio+dataclasses+ Django'sformat_html. Full documentation in the newdocs/website/guides/tutorials.mdguide with the simplest-possible example, state-machine description,TutorialStepreference,wait_for/timeoutcombinations table,on_enter/on_exitpatterns, 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 newWaiterMixin(automatically included inLiveView) that lets a@backgroundhandler suspend until a specific@event_handleris 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 byTutorialMixin(Phase 1c) and by any server-driven flow that needs to pause mid-plan until real user input arrives. Implementation: ~180 lines inpython/djust/mixins/waiters.py, a ~15-line hook inpython/djust/websocket.pythat calls_notify_waitersafter every successful handler invocation, a ~10-line cleanup hook in the WebSocketdisconnectpath that cancels all pending waiters when the view tears down (so@backgroundtasks unblock withCancelledErrorinstead of leaking), and proper integration intoLiveView's MRO viapython/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 wherewait_for_event("X")inside anXhandler 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 thedjust.waiterslogger, 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 raisesasyncio.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_waitersunblocks pending futures withCancelledErrorand clears the registry, task cancellation removes the waiter, and stability under mid-iteration waiter-list mutation. Full documentation in the existingdocs/website/guides/server-driven-ui.mdguide with signature, predicate examples, concurrency semantics, timeouts and cleanup, composition withpush_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:execclient-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 helperself.push_commands(chain)that takes adjust.js.JSChain(shipped in v0.4.1 as the JS Commands fluent API) and pushes it to the current session as adjust:execpush event carrying the chain's JSON-serializedopslist. The client half is a new framework-providedsrc/27-exec-listener.jsmodule that listens fordjust:push_eventCustomEvents onwindow, filters forevent === 'djust:exec', and runs the ops viawindow.djust.js._executeOps(ops, document.body)— the same function that runs inlinedj-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 withclient.jsand is active on every djust page automatically. The server-side helper is type-safe — it rejects anything that isn't aJSChainwith a clearTypeErrorpointing at theJS.*factory methods, preventing raw ops-list smuggling through thepush_eventpath.push_commandsandpush_eventshare 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 withpush_event, and per-op factory parity across all 11 JS Commands. 13 new JS tests intests/js/exec-listener.test.jscovering listener registration, single-op execution, multi-op ordering, multiple-classadd_class,focus,dispatchwith detail, filtering for non-djust:execevents, malformed-payload rejection (missingops, non-arrayops, missing detail), error resilience (one bad op doesn't break the chain), multiple independent exec fires, and end-to-end integration with the fluentwindow.djust.jschain factory. Zero new runtime dependencies. Full documentation indocs/website/guides/server-driven-ui.mdwith 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 apushescape hatch that mixes in server events when needed. Four equivalent entry points: (1) Python helperdjust.js.JS— fluent chain builder that stringifies to a JSON command list, wrapped inSafeStringfor safe template embedding (<button dj-click="{{ JS.show('#modal').add_class('active', to='#overlay') }}">Open</button>). (2) Client-sidewindow.djust.js— mirror of the Python API withcamelCasemethod names for direct JavaScript use (window.djust.js.show('#modal').addClass('active', {to: '#overlay'}).exec()). (3) Hook API — everydj-hookinstance now has athis.js()method returning a chain bound to the hook element (Phoenix 1.0 parity for programmable JS Commands from hook lifecycle callbacks). (4) Attribute dispatcher —dj-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>(absolutedocument.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. Thepushcommand acceptspage_loading=Trueto show the navigation-level loading bar while the event round-trips. Chains are immutable — every chain method returns a newJSChain, 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 +parseCommandValueedge cases). Zero new dependencies — the Python helper is stdlib-only and the JS interpreter is ~350 lines in a newsrc/26-js-commands.jsmodule. Full guide indocs/website/guides/js-commands.mdwith 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 theClipboardEventin one pass:text(clipboardData.getData('text/plain')),html(getData('text/html')for rich paste from Word/Google Docs/web pages),has_files(bool), andfiles(list of{name, type, size}metadata dicts for every file inclipboardData.files). When the element also carries adj-upload="<slot>"attribute, the clipboard'sFileListis routed through the existing upload pipeline — image-paste → chat, CSV-paste → table, etc. — via a newwindow.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; adddj-paste-suppressto 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 viakwargs["_args"]. 11 new JS tests covering text extraction, HTML extraction, file metadata, suppress flag, missingclipboardData, double-bind protection, positional args, upload routing with and without adj-uploadslot, and graceful degradation whengetData('text/html')throws. ~80 lines JS. Full guide indocs/website/guides/dj-paste.md. -
djust_audit --ast— AST security anti-pattern scanner (#660) — Adds a new mode todjust_auditthat 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 codesdjust.X001–djust.X007: X001 (ERROR) — possible IDOR:Model.objects.get(pk=...)inside a DetailView / LiveView without a sibling.filter(owner=request.user)(oruser=,tenant=,organization=,team=,created_by=,author=,workspace=) scoping the queryset. X002 (WARN) — state-mutating@event_handlerwithout any permission check (no class-levellogin_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 anurl_has_allowed_host_and_schemeoris_safe_urlguard in the enclosing function. X005 (ERROR) — unsafemark_safe/SafeStringwrapping an interpolated string (XSS risk). X006 (WARN) — template uses{{ var|safe }}(regex scan of.htmlfiles). X007 (WARN) — template uses{% autoescape off %}. Suppression via# djust: noqa X001on the offending line, or{# djust: noqa X006 #}inside templates. New CLI flags:--ast,--ast-path <dir>,--ast-exclude <prefix> [...],--ast-no-templates. Supports--jsonand--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 — stdlibast+re. Full documentation indocs/guides/djust-audit.mdanddocs/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_auditcommand guide —docs/guides/djust-audit.mddocuments 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 fromdocs/guides/security.md. -
Error code reference expanded with 44 new codes —
docs/guides/error-codes.mdnow 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()andWizardMixin.as_live_field()render form fields with proper CSS classes,dj-input/dj-changebindings, and framework-aware styling — but only for views backed by a DjangoFormclass. 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 noFormclass orWizardMixin. Supports 12 field types (text,textarea,select,password,email,number,url,tel,search,hidden,checkbox,radio), explicitevent=override (defaults sensibly per type —text→dj-input,select/radio/checkbox→dj-change,hidden→ none),debounce=/throttle=passthrough, framework CSS class resolution viaconfig.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 shareddjust._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. Seedocs/guides/live-input.mdfor the full setup guide. -
djust_audit --live <url>— runtime security-header and CSWSH probe (#661) — Adds a new mode todjust_auditthat fetches a running deployment with stdliburlliband 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 withOrigin: https://evil.exampleto 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 insettings.pybut the response is stripped, rewritten, or never emitted by the time it reaches the client — a downstream consumer pentest caught a criticalContent-Security-Policy missingcase this way (django-cspwas configured but the header was absent from production responses, stripped by an nginx ingress). 30 new stable finding codesdjust.L001–djust.L091cover 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--jsonand--strict(fail on warnings too). Zero new runtime dependencies — stdliburllibfor HTTP, optionalwebsocketspackage 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 fromcheck_configurationwhen Django runspython manage.py check: A001 (ERROR) — WebSocket router not wrapped inAllowedHostsOriginValidator(static-analysis companion to #653 for existing apps built from older scaffolds). A010 (ERROR) —ALLOWED_HOSTS = ["*"]in production. A011 (ERROR) —ALLOWED_HOSTSmixes"*"with explicit hosts (the wildcard makes the explicit entries meaningless). A012 (ERROR) —USE_X_FORWARDED_HOST=Truecombined with wildcardALLOWED_HOSTSenables Host header injection. A014 (ERROR) —SECRET_KEYstarts withdjango-insecure-in production (scaffold default not overridden before deployment). A020 (WARNING) —LOGIN_REDIRECT_URLis 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.admininstalled without a known brute-force protection package (django-axes,django-defender, etc.). Each check has essentially zero false-positive risk, has afix_hintpointing 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-levelsettings.pyvalues cover the common case. -
djust_audit --permissions permissions.yaml— declarative permissions document for CI-level RBAC drift detection (#657) — Adds a new flag todjust_auditthat 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_audittoday can tell "no auth" from "some auth", but not thatlogin_required=Trueshould have beenpermission_required=['claims.view_supervisor']. The permissions document IS the ground truth. Seven stable error codes (djust.P001throughdjust.P007) cover every deviation class. Also adds--dump-permissionsto bootstrap a starter YAML from existing code, and--strictto fail CI on any finding. Full documentation indocs/guides/permissions-document.md. Motivated by a downstream consumer pentest finding 10/11 where every view hadlogin_required=Trueset and djust_audit reported them all as protected, but the lowest-privilege authenticated user could ID-walk the entire database. -
WizardMixinfor multi-step LiveView form wizards — General-purpose mixin managing step navigation, per-step validation, and data collection for guided form flows. Providesnext_step,prev_step,go_to_step,update_step_field,validate_field, andsubmit_wizardevent handlers. Template context includes step indicators, progress, form data/errors, and pre-rendered field HTML viaas_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'fromscript-src/style-src— djust's inline<script>and<style>emissions (handler metadata bootstrap inTemplateMixin._inject_handler_metadata,live_sessionroute map inrouting.get_route_map_script, and the PWA template tagsdjust_sw_register,djust_offline_indicator,djust_offline_styles) now readrequest.csp_noncewhen available (set by django-csp whenCSP_INCLUDE_NONCE_INcovers the relevant directive) and emit anonce="..."attribute on the tag. When no nonce is available (django-csp not installed, orCSP_INCLUDE_NONCE_INnot set), the tags emit without a nonce attribute — fully backward compatible with apps still allowing'unsafe-inline'. Apps that want strict CSP can now setCSP_INCLUDE_NONCE_IN = ("script-src", "script-src-elem", "style-src", "style-src-elem")insettings.py, drop'unsafe-inline'fromCSP_SCRIPT_SRC/CSP_STYLE_SRC, and get strict CSP XSS protection across all djust-generated inline content. The PWA tagsdjust_sw_register,djust_offline_indicator, anddjust_offline_stylesnow usetakes_context=Trueto read the request from the template context — they still work with the same template syntax ({% djust_sw_register %}etc.) as long as aRequestContextis used (Django's default for template rendering). Seedocs/guides/security.mdfor 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_TIMING—LiveViewConsumerpreviously attachedtiming(handler/render/total ms) andperformance(full nested timing tree with handler and phase names) to every VDOM patch response unconditionally, regardless ofsettings.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 whensettings.DEBUGor the newsettings.DJUST_EXPOSE_TIMINGis True. Upgrade notes: production behavior change — existing clients that consumedresponse.timing/response.performancein production will no longer see those fields; opt in viaDJUST_EXPOSE_TIMING = Truein settings for staging/profiling. The browser debug panel is unaffected (it receives timing via the existing_attach_debug_payloadpath, which is already gated onDEBUG). 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 theOriginheader, andDjustMiddlewareStackdid 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, andDjustMiddlewareStackwraps its inner application inchannels.security.websocket.AllowedHostsOriginValidatorby default (defense in depth). Missing Origin is still allowed so non-browser clients (curl, testWebsocketCommunicator) continue to work. Upgrade notes: ensuresettings.ALLOWED_HOSTSdoes NOT contain*in production; if you need to opt out for a specific stack, useDjustMiddlewareStack(inner, validate_origin=False)(not recommended). Reported via external penetration test 2026-04-10. (#653) -
Enforce
login_requiredon HTTP GET path — Views withlogin_required = Truerendered 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 callscheck_view_auth()beforemount()on HTTP GET and returns 302 toLOGIN_URL. Also callshandle_params()aftermount()on HTTP GET to match the WebSocket path's behavior, preventing state flash on URL-param-dependent views. (#636, fixes #633, #634)
Fixed
-
Prevent
SynchronousOnlyOperationinPerformanceTracker.track_context_size— The tracker calledsys.getsizeof(str(context)), which triggeredQuerySet.__repr__()on any unevaluated querysets in the context dict.__repr__callslist(self[:21]), evaluating the queryset against the database — raisingSynchronousOnlyOperationin the async WebSocket path. Now uses a shallow per-valuegetsizeofsum 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 group —
applyPatchesinclient.js:1379-1440was filteringInsertChildpatches out of each parent group and applying them viaDocumentFragmentbefore iterating the group for theRemoveChildpatches 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-patchon<a>tags uses href when attribute value is empty — Booleandj-patchon anchor elements (<a href="?tab=docs" dj-patch>) was resolving to the current URL instead of the href destination. Now falls back toel.getAttribute('href')whendj-patchis empty and the element is<a>. (#640) -
Normalize Model instances in
render_full_templatebefore passing to Rust — Django FK fields are class-level descriptors not present in__dict__. Rust'sFromPyObjectextracts__dict__which hasclaimant_id=1(raw FK int) instead of the related object. Now always callsnormalize_django_value()on pre-serialized context so FK relationships are resolved viagetattr()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 extractedForm.__dict__which doesn't contain computedBoundFieldattributes. Now pre-renders Form and BoundField objects to SafeString HTML viawidget.render()in all four code paths (serialization, template serialization, template rendering, and LiveView state sync). (#631, fixes #621) -
Correct
has_idsattribute name in WebSocket mount response —websocket.pychecked 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
.valuefrom 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.valueDOM property. Now syncs.valuefrom the attribute inpreserveFormValues(), broadcast patches, andmorphElement(). 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,prototypekeys), replaced direct property assignment withObject.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
whitenoisedependency — djust'sASGIStaticFilesHandlerindjust.asgi.get_application()already handles static file serving at the ASGI layer, making WhiteNoise middleware redundant. Removedwhitenoisefrom dependencies, scaffolded projects, and the demo project. Removed system checkC006(daphne without WhiteNoise). (#584)
Added
-
{% dj_flash %}template tag in Rust renderer — RegisteredDjFlashTagHandlerso 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 class —
djust:navigate-start/djust:navigate-endCustomEvents and.djust-navigatingCSS class on[dj-root]duringdj-navigatetransitions. Enables CSS-only page transitions without monkey-patchingpageLoading. (#585)See
docs/website/core-concepts/events.md. -
manage.py djust_doctordiagnostic 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--verboseflags. -
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). InDEBUG_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_errorincludesdebug_detail(unsanitized message),traceback(last 3 frames), andhint(actionable suggestion) whensettings.DEBUG=True.handle_mountlists available LiveView classes when class lookup fails.See
docs/website/guides/error-overlay.md. -
Debug panel warning interceptor -- intercepts
console.warncalls matching[LiveView]prefix and surfaces them as a warning badge on the debug button. Configurable auto-open viaLIVEVIEW_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-changeordj-inputautomatically fire change events to restore server state. Compares DOM values against server-rendered defaults and only fires for fields that differ. Usedj-no-recoverto opt out individual fields. Fields insidedj-auto-recovercontainers 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-bannerCSS class) and exposed viadata-dj-reconnect-attemptattribute and--dj-reconnect-attemptCSS custom property on<body>. Banner and attributes cleared on successful reconnect or intentional disconnect.See
docs/website/guides/reconnection.md. -
page_title/page_metadynamic document metadata — Updatedocument.titleand<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). Supportsog:andtwitter:meta tags with correctpropertyattribute. Works over both WebSocket and SSE transports. -
dj-copyenhancements — Selector-based copy (dj-copy="#code-block"copies the element'stextContent), configurable feedback text (dj-copy-feedback="Done!"), CSS class feedback (dj-copy-classadds a custom class for 2s, defaultdj-copied), and optional server event (dj-copy-event="copied"fires after successful copy for analytics). Backward compatible with existing literal copy behavior. -
dj-auto-recoverattribute for reconnection recovery — After WebSocket reconnects, elements withdj-auto-recover="handler_name"automatically fire a server event with serialized DOM state (form field values anddata-*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-throttleHTML attributes — Apply debounce or throttle to anydj-*event attribute (dj-click,dj-change,dj-input,dj-keydown,dj-keyup) directly in HTML:<button dj-click="search" dj-debounce="300">. Takes precedence overdata-debounce/data-throttle. Supportsdj-debounce="blur"to defer until element loses focus (Phoenix parity).dj-debounce="0"disables default debounce ondj-input. Each element gets its own independent timer. -
Connection state CSS classes —
dj-connectedanddj-disconnectedclasses 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'sphx-connected/phx-disconnectedequivalent. -
dj-cloakattribute for FOUC prevention — Elements withdj-cloakare 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'sphx-no-feedbackequivalent. -
Page loading bar for navigation transitions — NProgress-style thin loading bar at the top of the page during TurboNav and
live_redirectnavigation. Always active by default. Exposed aswindow.djust.pageLoadingwithstart(),finish(), andenabledfor manual control. Disable viawindow.djust.pageLoading.enabled = falseor CSS override.See
docs/website/guides/navigation.md. -
dj-scroll-into-viewattribute for auto-scroll on render — Elements withdj-scroll-into-vieware 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 onwindowordocumentwhile using the declaring element for context extraction (component_id, dj-value-* params). Supportsdj-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 asdj-keydown. Scroll and resize events default to 150ms throttle. Phoenix LiveView'sphx-window-*equivalent, plusdj-document-*as a djust extension.See
docs/website/core-concepts/events.md. -
dj-click-awayattribute — Fire a server event when the user clicks outside an element:<div dj-click-away="close_dropdown">. Uses capture-phase document listener sostopPropagation()inside the element doesn't prevent detection. Supportsdj-confirmfor confirmation dialogs anddj-value-*params from the declaring element. -
dj-shortcutattribute 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">. Supportsctrl,alt,shift,metamodifiers, comma-separated multiple bindings, andpreventmodifier to suppress browser defaults. Shortcuts are automatically skipped when the user is typing in form inputs (override withdj-shortcut-in-inputattribute). Event params includekey,code, andshortcut(the matched binding string). -
_targetparam in form change/input events — When multiple form fields share onedj-changeordj-inputhandler, the_targetparam now includes the triggering element'sname(orid, ornull), letting the server know which field changed. Fordj-submit, includes the submitter button's name if available. Matches Phoenix LiveView's_targetconvention.See
docs/website/core-concepts/events.md. -
dj-disable-withattribute 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 bothdj-submitforms anddj-clickbuttons. Original text is restored after server response. -
dj-lockattribute 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 adjust-lockedCSS class instead of thedisabledproperty. All locked elements are unlocked on server response.See
docs/website/core-concepts/events.md. -
dj-mountedevent for element lifecycle — Fire a server event when an element withdj-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). Includesdj-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 withsource="broadcast"andsource="async"respectively, and the client buffers them during pending user event round-trips (same as tick buffering from #560).server_pushnow acquires the render lock and yields to in-progress user events to prevent version interleaving. Client-side pending event tracking upgraded from single ref toSet-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_handlerCRUD operations), urls.py (usinglive_session()routing), HTML template (withdj-*directives), and tests.py. Supports--dry-run,--force,--no-tests,--api(JSON mode) options. Handles all Django field types including FK relationships. Search usesQobjects for OR logic across text fields.See
docs/guides/scaffolding.md. -
on_mounthooks for cross-cutting mount logic — Module-level hooks that run on every LiveView mount, declared via@on_mountdecorator andon_mountclass attribute. Use cases: authentication checks, telemetry, tenant resolution, feature flags. Hooks run after auth checks, beforemount(). Return a redirect URL string to halt the mount pipeline. Hooks are inherited via MRO (parent-first, deduplicated). Includes V009 system check for validation. Phoenixon_mountv0.17+ parity.See
docs/website/guides/on-mount-hooks.md. -
put_flash(level, message)andclear_flash()for ephemeral flash notifications — Phoenixput_flashparity. 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 ARIArole="status"/role="alert"support. (#568)See
docs/website/guides/flash-messages.md. -
handle_paramscalled on initial mount —handle_params(params, uri)is now invoked aftermount()on the initial WebSocket connect, not just on subsequent URL changes. This matches Phoenix LiveView'shandle_params/3contract and eliminates the need to duplicate URL-parsing logic betweenmount()andhandle_params(). Views that don't overridehandle_paramsare unaffected (default is a no-op).See
docs/website/core-concepts/liveview.md. -
dj-value-*— Static event parameters — Pass static values alongside events withoutdata-*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'sphx-value-*equivalent.See
docs/website/core-concepts/events.md.
Fixed
-
True/False/Noneliterals resolved as empty string in custom tag args —get_value()didn't recognize Python boolean/None literals, so{% tag show_labels=False %}producedshow_labels=(empty string) instead ofshow_labels=False. Now handlesTrue/true,False/false, andNone/noneas literal values. (#602) -
Flash and page_metadata not delivered over HTTP POST fallback —
put_flash()andpage_title/page_metaside-channel commands were only flushed over WebSocket. HTTP POST responses now drain_pending_flashand_pending_page_metadataand include them as_flashand_page_metadataarrays in the JSON response. (#590) -
Custom tag args containing lists/objects serialized as
[List]/[Object]—Value::ListandValue::Objectin custom tag arguments were stringified via theDisplaytrait, destroying structured data before it reached Python handlers. Now serialized as JSON viaserde_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 usedcontext.get()(plain lookup) instead ofget_value()(filter-aware). (#591) -
{% if %}inside HTML tag after{{ variable }}emits<!--dj-if-->comment —is_inside_html_tag()only checked the immediately preceding token, missing tag context when{{ variable }}tokens appeared between the tag opening and{% if %}. Addedis_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.Lockto 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 theapplyPatches()cycle to capture and restoreactiveElement,selectionStart/selectionEnd, andscrollTop/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 (getSignificantChildrenandgetNodeByPath), causing path traversal errors and silent patch failures. Also added#commenthandling tocreateNodeFromVNodeso comment placeholders can be correctly created duringInsertChildpatches. (#559)
[0.3.8] - 2026-03-19
Fixed
- Tick auto-refresh causes VDOM version mismatch, silently drops user events —
_run_tickalways calledrender_with_diff()even whenhandle_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_assignsto 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 usesrequest.path(the actual page URL) so each view gets its own VDOM baseline. (#561) - Canvas
width/heightcleared duringhtml_updatemorph —morphElementremoved attributes absent from server HTML, resetting canvas 2D contexts and blanking Chart.js charts. Canvaswidthandheightare now preserved during attribute sync. (#561) _force_full_htmlnot checked inhandle_url_change— Views that set_force_full_html = Trueinhandle_params(e.g., when{% for %}loop lengths change) still received VDOM patches instead of full HTML. The flag is now checked afterrender_with_diff()in bothhandle_eventandhandle_url_change. (#559, #561)
Added
dj-patchon selects/inputs uses WSurl_change— Select and input elements withdj-patchnow update via pushState + WebSocketurl_changeinstead of full page reload. A delegateddocumentchange listener survives DOM replacement by morphdom.dj-patch-reloadattribute 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
FormMixinwithModelFormover WebSocket: added@event_handlertosubmit_form()andvalidate_field(); renamedform_instanceto private_form_instancewith backward-compatible property; storemodel_pk/model_labelas public attributes for re-hydration after WS session restore; syncform_datafrom saved instance afterform_valid(); use FK PK instead of related object; auto-populateform_choiceswith serializable tuples. (#545) dj-hookelements not re-initialized afterhtml_updateorhtml_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). AddedupdateHooks()to all DOM replacement paths:html_update,html_recovery, TurboNav reinit, embedded view update, lazy hydration, and streaming updates. (#548)__version__not updated bymake version—make versiononly updatedpyproject.tomlandCargo.tomlbut not the hardcoded__version__in__init__.pyfiles.djust.__version__now stays in sync with the package version. (#547)
Changed
- Extract
reinitAfterDOMUpdate()to DRY up post-DOM-update calls — The repeated pattern ofinitReactCounters()+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-linegetComponentId/getEmbeddedViewIdpattern appeared 4 times in event binding; now a single helper. (#551) - Extract
isWSConnected()to replace WebSocket state guard chains — TheliveViewWS && liveViewWS.ws && liveViewWS.ws.readyState === WebSocket.OPENpattern appeared across 4 files; now a single predicate. (#552) - Extract
clearOptimisticPending()to consolidate CSS class cleanup — ThequerySelectorAll('.optimistic-pending')removal loop appeared 4 times across 2 files; now a single function. (#553) - Standardize
DJUST_CONFIGaccess viaget_djust_config()— Replaced 10+ inlinegetattr(settings, "DJUST_CONFIG", {})try/except blocks across tenants, PWA, and storage modules with a singleget_djust_config()helper inconfig.py. (#554) - Extract generic
BackendRegistryclass — The duplicated lazy-init / set / reset pattern instate_backends/registry.pyandbackends/registry.pynow delegates to a sharedBackendRegistryclass inutils.py. (#555) - Extract
is_model_list()helper — The repeatedisinstance(value, list) and value and isinstance(value[0], models.Model)check is now a singleis_model_list()function inutils.py, used inmixins/context.pyandmixins/request.py. (#556)
[0.3.6] - 2026-03-14
Breaking Changes
model.idnow returns the native type, not a string —_serialize_model_safely()previously wrappedobj.pkwithstr()when producing the"id"key, causing template comparisons like{% if edit_id == todo.id %}to fail silently whenedit_idwas an integer.model.idnow matchesmodel.pkand returns the native Python type (e.g.int,UUID). Migration: if your templates or event handlers comparemodel.idagainst 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 sendshas_prerendered=trueon 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-runningmount(). This eliminates the double page-load cost for views with expensivemount()implementations (e.g. directory scans, API calls). Falls back to callingmount()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 hadself.tenant=Noneon WS connect for pre-rendered pages. (#542) djust cache --allnow correctly clears all sessions on the Redis backend — The CLI calledcleanup_expired(ttl=0)to force-clear sessions, but the semantics ofttl=0changed in 0.3.5 to mean "never expire". The command now calls the explicitdelete_all()method, which uses a Redis pipeline for an efficient single round-trip bulk delete. (#409)dj-paramsattribute no longer silently dropped — Between 0.3.2 and 0.3.6rc2,dj-paramswas removed from the client event-binding code. Templates usingdj-params='{"key": value}'continued to fire click events but the server receivedparams: {}. The attribute is now read and merged into the params object for backward compatibility. Aconsole.warnis emitted in debug mode (globalThis.djustDebug) to notify developers to migrate. (#469)- Prefetch Set not cleared on SPA navigation — The client-side
_prefetchedSet persisted acrosslive_redirectnavigations, preventing links on the new view from being prefetched. Addedclear()towindow.djust._prefetchand call it inhandleLiveRedirect()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: falseto signal the client. (#421) {% djust_pwa_head %}and other custom tags with quoted arguments containing spaces now render correctly — The Rust template lexer usedsplit_whitespace()to tokenize tag arguments, which broke quoted values likename="My App"into separate tokens (name="MyandApp"). 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 %}asNode::Comment, whichnodes_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 dedicatedNode::Loadvariant that preserves library names through reconstruction. Also improved_render_django_tag()error handling: failures now log a full traceback vialogger.exception()and return a visible HTML comment instead of an empty string. (#418)- Checkbox/radio
checkedand<option>selectedstate not updated by VDOM patches —SetAttrandRemoveAttrpatches only calledsetAttribute/removeAttribute, which updates the HTML attribute but not the DOM property. After user interaction the browser separates the two, so server-driven state changes viadj-clickhad no visible effect on checkboxes, radios, or select options. Fixed by syncing the DOM property alongside the attribute. Also fixedcreateNodeFromVNodeto set.checked/.selectedwhen creating new elements. (#422) SESSION_TTL=0breaks all event handling (no DOM patches) —cleanup_expired()methods in bothInMemoryStateBackendandRedisStateBackendnow treatTTL ≤ 0as "never expire". PreviouslySESSION_TTL=0causedcutoff = 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— Replacedhasattr(scope_session, "session_key")withgetattr(scope_session, "session_key", None)in the consumer's request context builder.hasattr()on a Django ChannelsLazyObjectcan raise non-AttributeErrorexceptions during lazy evaluation, causing the consumer to crash silently. (#396)
Deprecated
-
dj-paramsJSON blob attribute — Use individualdata-*attributes with optional type-coercion suffixes instead.dj-paramswill 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 tosnake_casefor server handler parameters.
Added
-
djust-deployCLI — newpython/djust/deploy_cli.pymodule providing deployment commands for djustlive.com. Available via thedjust-deployentry point after installation. (#437)djust-deploy login— prompts for email/password, authenticates against djustlive.com, and stores the token in~/.djustlive/credentials(mode0o600)djust-deploy logout— calls the server logout endpoint and removes the local credentials filedjust-deploy status [project]— fetches current deployment state; optionally filtered by project slugdjust-deploy deploy <project-slug>— validates the git working tree is clean, triggers a production deployment, and streams build logs to stdout--serverflag /DJUST_SERVERenv var to override the default server URL (https://djustlive.com) Seedocs/website/guides/djust-deploy.md.
-
TypeScript type stubs updated —
DjustStreamOpnow includes"done"and"start"operation types and an optionalmodefield ("append" | "replace" | "prepend").getActiveStreams()return type changed fromMaptoRecord. Seedocs/website/guides/typecheck.md. -
.flex-betweenCSS utility class — Added to demo project'sutilities.cssfor 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) Seedocs/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 toPostProcessingMixinincluded 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 / totalcount 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 sameN / totalcount label (#520). OverlappingnameFilterandsearchQueryon 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 sendshas_prerendered=trueon 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-runningmount(). This eliminates the double page-load cost for views with expensivemount()implementations (e.g. directory scans, API calls). Falls back to callingmount()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 hadself.tenant=Noneon WS connect for pre-rendered pages. (#542)
[0.3.6rc3] - 2026-03-13
Breaking Changes
model.idnow returns the native type, not a string —_serialize_model_safely()previously wrappedobj.pkwithstr()when producing the"id"key, causing template comparisons like{% if edit_id == todo.id %}to fail silently whenedit_idwas an integer.model.idnow matchesmodel.pkand returns the native Python type (e.g.int,UUID). Migration: if your templates or event handlers comparemodel.idagainst 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 --allnow correctly clears all sessions on the Redis backend — The CLI calledcleanup_expired(ttl=0)to force-clear sessions, but the semantics ofttl=0changed in 0.3.5 to mean "never expire". The command now calls the explicitdelete_all()method, which uses a Redis pipeline for an efficient single round-trip bulk delete. (#409)dj-paramsattribute no longer silently dropped — Between 0.3.2 and 0.3.6rc2,dj-paramswas removed from the client event-binding code. Templates usingdj-params='{"key": value}'continued to fire click events but the server receivedparams: {}. The attribute is now read and merged into the params object for backward compatibility. Aconsole.warnis emitted in debug mode (globalThis.djustDebug) to notify developers to migrate. (#469)- Prefetch Set not cleared on SPA navigation — The client-side
_prefetchedSet persisted acrosslive_redirectnavigations, preventing links on the new view from being prefetched. Addedclear()towindow.djust._prefetchand call it inhandleLiveRedirect()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: falseto signal the client. (#421) {% djust_pwa_head %}and other custom tags with quoted arguments containing spaces now render correctly — The Rust template lexer usedsplit_whitespace()to tokenize tag arguments, which broke quoted values likename="My App"into separate tokens (name="MyandApp"). 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 %}asNode::Comment, whichnodes_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 dedicatedNode::Loadvariant that preserves library names through reconstruction. Also improved_render_django_tag()error handling: failures now log a full traceback vialogger.exception()and return a visible HTML comment instead of an empty string. (#418)- Checkbox/radio
checkedand<option>selectedstate not updated by VDOM patches —SetAttrandRemoveAttrpatches only calledsetAttribute/removeAttribute, which updates the HTML attribute but not the DOM property. After user interaction the browser separates the two, so server-driven state changes viadj-clickhad no visible effect on checkboxes, radios, or select options. Fixed by syncing the DOM property alongside the attribute. Also fixedcreateNodeFromVNodeto set.checked/.selectedwhen creating new elements. (#422) SESSION_TTL=0breaks all event handling (no DOM patches) —cleanup_expired()methods in bothInMemoryStateBackendandRedisStateBackendnow treatTTL ≤ 0as "never expire". PreviouslySESSION_TTL=0causedcutoff = 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— Replacedhasattr(scope_session, "session_key")withgetattr(scope_session, "session_key", None)in the consumer's request context builder.hasattr()on a Django ChannelsLazyObjectcan raise non-AttributeErrorexceptions during lazy evaluation, causing the consumer to crash silently. (#396)
Deprecated
-
dj-paramsJSON blob attribute — Use individualdata-*attributes with optional type-coercion suffixes instead.dj-paramswill 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 tosnake_casefor server handler parameters.
Added
-
djust-deployCLI — newpython/djust/deploy_cli.pymodule providing deployment commands for djustlive.com. Available via thedjust-deployentry point after installation. (#437)djust-deploy login— prompts for email/password, authenticates against djustlive.com, and stores the token in~/.djustlive/credentials(mode0o600)djust-deploy logout— calls the server logout endpoint and removes the local credentials filedjust-deploy status [project]— fetches current deployment state; optionally filtered by project slugdjust-deploy deploy <project-slug>— validates the git working tree is clean, triggers a production deployment, and streams build logs to stdout--serverflag /DJUST_SERVERenv var to override the default server URL (https://djustlive.com) Seedocs/website/guides/djust-deploy.md.
-
TypeScript type stubs updated —
DjustStreamOpnow includes"done"and"start"operation types and an optionalmodefield ("append" | "replace" | "prepend").getActiveStreams()return type changed fromMaptoRecord. Seedocs/website/guides/typecheck.md. -
.flex-betweenCSS utility class — Added to demo project'sutilities.cssfor 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) Seedocs/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 toPostProcessingMixinincluded 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 / totalcount 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 sameN / totalcount label (#520). OverlappingnameFilterandsearchQueryon 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-deployCLI — newpython/djust/deploy_cli.pymodule providing deployment commands for djustlive.com. Install withpip install djust[deploy]. Available via thedjust-deployentry point:djust-deploy login— prompts for email/password, authenticates against djustlive.com, and stores the token in~/.djustlive/credentials(mode0o600)djust-deploy logout— calls the server logout endpoint and removes the local credentials filedjust-deploy status [project]— fetches current deployment state; optionally filtered by project slugdjust-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-hookelements now initialize afterdj-navigatenavigation —updateHooks()is called afterlive_redirect_mountreplaces 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) whenDEBUG=False, hiding the error message and stack trace. Now logs type, message, and traceback atERRORlevel regardless ofDEBUGmode. Client responses remain generic in production. (#415) - DJE-053 no longer fires as a warning for idempotent event handlers — When an
@event_handlerruns 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 atDEBUGlevel rather than logged as aWARNING. This matches Phoenix LiveView behaviour. TheWARNING-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 %}blocks —InsertChildandRemoveChildpatches now includeref_dandchild_dfields 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 —
.pyistubs forlive_redirect,live_patch,push_event,stream, and related methods so mypy/pyright catch typos at lint time. (#390) Seedocs/website/guides/typecheck.md. - Navigation Patterns guide — Documents when to use
dj-navigatevslive_redirectvslive_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.mdcovering 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 framework —components/base.pynow usesformat_html()to avoid XSS risk in component rendering. (#390)- Exception details no longer exposed in production —
render_template()previously returnedf"<div>Error: {e}</div>"unconditionally, leaking internal Rust template engine details. Now returns a generic message in production; error details are only shown whensettings.DEBUG = True. (#385) - Playground XSS fixed — Replaced
innerHTMLassignment 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 subclasses —TenantAwareRedisBackend,TenantAwareMemoryBackend, and several example components were missingsuper().__init__()calls, causing MRO issues. (#386)- Unused
escapeimport removed fromdata_table.py— CodeQL alert resolved. (#387) render_full_templatesignature mismatch fixed —no_template_demo.pyoverride now correctly acceptsserialized_context. (#387)- V004 false positives on lifecycle methods —
handle_params(),handle_disconnect(),handle_connect(), andhandle_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 inmount(). (#398) - Test isolation —
test_checks.pyanddouble_bind.test.jsno 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) Seedocs/website/guides/template-cheatsheet.md. - System checks
djust.T011/T012/T013— Warns at startup for unsupported Rust template tags, missingdj-view, and invaliddj-viewpaths. (#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 propagatein_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 invocationin debug panel on Chrome/Edge. (#367)dj-patch('/')now correctly updates browser URL to root path. (#307)live_patchrouting restored —handleNavigationdispatch 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 propagatesin_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 threadingin_tag_contextas a parameter intoparse_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_wrapperand 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 containsdj-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, causingrender_full_templateto attempt rendering a non-standalone template. (#366) TypeError: Illegal invocationin debug panel on Chrome/Edge —_hookExistingWebSocketcalled native WebSocket getter/setter functions viaFunction.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 ofdesc.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) Seedocs/website/guides/template-cheatsheet.md. - System check
djust.T011for 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.T012for missingdj-view— Detects templates that usedj-*event directives without adj-viewattribute, which would silently fail at runtime. (#293) Seedocs/website/getting-started/first-liveview.md. - System check
djust.T013for invaliddj-viewpaths — Detects empty or malformeddj-viewattribute values. (#293) Seedocs/website/getting-started/first-liveview.md. {% now %}supports 35+ Django date format specifiers — IncludingS(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 specialPformat (noon/midnight).- Deployment guides — Added deployment documentation for Railway, Render, and Fly.io. (#247)
- Navigation best practices documentation — Documented
dj-patchvsdj-clickfor client-side navigation, withhandle_params()patterns. (#304) Seedocs/guides/BEST_PRACTICES.md. - LiveView invariants documentation — Documented root container requirement and
**kwargsconvention 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 theurl.pathname !== '/'guard inbindNavigationDirectivesthat prevented the browser URL from being updated when patching to/. The guard was silently ignoring root-path patches. (#307)live_patchrouting restored —handleNavigationdispatch now fires correctly — Fixed dict merge order in_flush_navigationsotype: 'navigation'is no longer overwritten by**cmd. Added anactionfield to carry the nav sub-type (live_patch/live_redirect);handleNavigationnow dispatches ondata.actioninstead ofdata.type. Previously the clientswitch case 'navigation':never matched becausetypewas being overwritten with"live_patch". Note:data.action || data.typefallback is kept for old JS clients that send messages without anactionfield — 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 alongsidedj-*directives. (#331)
[0.3.2] - 2026-02-18
Added
- TypeScript definitions (
djust.d.ts) — Comprehensive ambient TypeScript declaration file shipped with the Python package atstatic/djust/djust.d.ts. Covers:window.djustnamespace,LiveViewWebSocketandLiveViewSSEtransport classes,DjustHooklifecycle interface (mounted,beforeUpdate,updated,destroyed,disconnected,reconnected),DjustHookContext(this.el,this.pushEvent,this.handleEvent),dj-modelbinding types, streaming API types (DjustStreamMessage,DjustStreamOp), upload progress event types (DjustUploadEntry,DjustUploadConfig,DjustUploadProgressEventDetail), and thedjust:upload:progresscustom DOM event. Use via/// <reference path="..." />or add totsconfig.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:
EventSourcefor 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 withpath("djust/", include(djust.sse.sse_urlpatterns))and include03b-sse.jsin your template. Feature limitations: no binary file uploads, no presence tracking, no actor-based state. Seedocs/sse-transport.mdfor full setup guide. - Type stub files (.pyi) for LiveView and mixins — Added PEP 561 compliant type stubs for
NavigationMixin,PushEventMixin,StreamsMixin,StreamingMixin, andLiveViewto enable IDE autocomplete and mypy type checking for runtime-injected methods likelive_redirect,live_patch,push_event,stream,stream_insert,stream_delete, andstream_to. Includespy.typedmarker file and comprehensive test suite. @backgrounddecorator for async event handlers — New decorator that automatically runs the entire event handler in a background thread viastart_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 includeasync_pendingflag when astart_async()callback is running, preventing loading spinners from disappearing prematurely. Async completion responses includeevent_nameso the client clears the correct loading state. Supports named tasks for tracking and cancellation viacancel_async(name). Optionalhandle_async_result(name, result, error)callback for completion/error handling. (#313, #314) Seedocs/website/guides/loading-states.md.dj-loading.forattribute — Scope anydj-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)AsyncWorkMixinincluded inLiveViewbase class —start_async()is now available on all LiveViews without explicit mixin import. (#314) Seedocs/website/guides/loading-states.md.- Loading state re-scan after DOM patches —
scanAndRegister()is called after everybindLiveViewEvents()so dynamically rendered elements (e.g., inside modals) get loading state registration. Stale entries for disconnected elements are cleaned up automatically. (#314) Seedocs/website/guides/loading-states.md. - System check
djust.T010for dj-click navigation antipattern — Detects elements usingdj-clickwith navigation-related data attributes (data-view,data-tab,data-page,data-section). This pattern should usedj-patchinstead for proper URL updates, browser history support, and bookmarkable views. Warning severity. (#305) - System check
djust.Q010for navigation state in event handlers — Heuristic INFO-level check that detects@event_handlermethods setting navigation state variables (self.active_view,self.current_tab, etc.) without usingpatch()orhandle_params(). Suggests converting todj-patchpattern for URL updates and back-button support. Can be suppressed with# noqa: Q010. (#305) - Type stubs for Rust extension and LiveView — Added
.pyitype stub files for_rustmodule andLiveViewclass, enabling IDE autocomplete, mypy/pyright type checking, and catching typos likelive_navigate(should belive_patch) at lint time. Includespy.typedmarker for PEP 561 compliance and comprehensive documentation indocs/TYPE_STUBS.md.
Deprecated
data.typefallback inhandleNavigation— Thedata.action || data.typefallback for pre-#307 clients (added for backwards compatibility in #318) will be removed in the next minor release. Server now sendsdata.actionon all navigation messages. Update any custom client code that sends navigation messages without anactionfield.
Fixed
- Silent
str()coercion for non-serializable LiveView state — Non-serializable objects stored inself.*duringmount()(e.g., service instances, API clients) were silently converted to strings, causing confusingAttributeErroron 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) raisesTypeErrorinstead of coercing, recommended for development. New static checkdjust.V008(AST-based) detects non-primitive assignments inmount()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 withlogin_required = Falsewere incorrectly flagged as missing authentication due to a truthy test. The check now uses explicitis not Nonecomparisons to distinguish intentional public access from unaddressed auth. (#303) |safefilter 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, causingdiff_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 generatesRemoveChild+InsertChildpatches instead ofReplacepatches for semantic consistency. Eliminates DJE-053 fallback to full HTML updates and removes need forstyle='display:none'workarounds. (#295) - Event listener leak causing duplicate WebSocket sends — Single user actions were triggering the same event multiple times (e.g.
select_project5×,mount3×) because listeners accumulated across VDOM patch/morph cycles without cleanup. Fixed four root causes: (1)initReactCountersnow uses aWeakSetguard to skip already-initialized containers; (2)createNodeFromVNodeno longer pre-marks elements as bound beforebindLiveViewEvents()runs, eliminating a race where newly inserted elements were silently skipped; (3)dj-clickhandlers now read the attribute at fire-time rather than bind-time, somorphElementattribute updates take effect immediately; (4) three unguardedconsole.logcalls in12-vdom-patch.jsare now wrapped inif (globalThis.djustDebug). The existingWeakMap-based deduplication inbindLiveViewEvents()(introduced in #312) correctly prevents re-binding when called repeatedly. (#315) dj-patch('/')failed to update URL andlive_patchrouting broken — Removedurl.pathname !== '/'guard inbindNavigationDirectivesso root-path navigation works. Fixed dict merge order in_flush_navigationso server sendstype='navigation'instead oftype='live_patch'. UpdatedhandleNavigationto dispatch viadata.actionwithdata.action || data.typefallback for backwards compatibility. (#318)- 52 unguarded
console.logcalls in client JS — Allconsole.logcalls across 12 files instatic/djust/src/(excluding the intentional debug panel insrc/debug/) are now wrapped withif (globalThis.djustDebug). Bare logging in production code leaks internal state to browser consoles and violates thedjust.Q003system 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 patches —
createNodeFromVNodenow correctly collectsFormDatafor submit events; replaceddata-liveview-*-boundattribute tracking withWeakMapto 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
|safefilter with nested dicts — Added comprehensive tests verifying that|safefilter 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,HTMLFormElementreferences in params corrupted the JSON payload, overwriting form field data with the element's indexed children. (#308) @change→dj-changein form adapters — All three framework adapters (Bootstrap 5, Tailwind, Plain) rendered@change="validate_field"instead ofdj-change="validate_field", causing real-time field validation to silently fail. (#310)EmailFieldrendered astype="text"—_get_field_type()checkedCharFieldbeforeEmailField(which inherits fromCharField), so email fields never gottype="email". Reordered the isinstance checks. (#310)
Security
- XSS in
FormMixin.render_field()— Removedrender_field(),_render_field_widget(), and_attrs_to_string()fromFormMixin. These methods used f-strings with no escaping to build HTML, allowing stored XSS via form field values. Useas_live()/as_live_field()(which delegate to framework adapters with properescape()) instead. (#310) - Textarea content not escaped in adapters —
_render_input()passed raw textarea values to_build_tag()content withoutescape(). Addedescape(str(value))for textarea content. (#310)
Changed
- Framework adapters deduplicated — Created
BaseAdapterwith all shared rendering logic.Bootstrap5Adapter,TailwindAdapter, andPlainAdapterreduced from ~200 lines each to ~10 lines of class attributes.frameworks.pyreduced from ~657 to ~349 lines. (#310) _model_instancesupport for ModelForm editing —FormMixin.mount()now reads field values from_model_instanceif set and the form is aModelForm._create_form()passesinstance=to the form constructor. (#310)
Deprecated
LiveViewForm— EmitsDeprecationWarningon subclass. Adds no functionality overdjango.forms.Form. Will be removed in 0.4. (#310)
Removed
FormMixin.render_field()— Insecure (XSS via f-strings) and duplicated adapter logic. Useas_live_field()instead. (#310)form_field()function — Dead code, never called. Removed fromforms.pyand__all__. (#310)
[0.3.1] - 2026-02-14
Changed
- 3.8x faster rendering for large pages — Optimized
get_context_data()by replacingdir(self)iteration (~300 inherited Django View attributes, ~50ms) with targeted__dict__+ MRO walk (<1ms). Addeddj-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-confirmattribute — Declarative confirmation dialogs for event handlers. Adddj-confirm="Are you sure?"to anydj-clickelement 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 manualclient.jsloading. (2) Graceful fallback auto-injects Tailwind CDN in development mode whenoutput.cssis missing. (3) CLI helper commandpython manage.py djust_setup_css tailwindcreatesinput.csswith Tailwind v4 syntax, auto-detects template directories, finds Tailwind CLI, and builds CSS with optional--watchand--minifyflags. 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-sideautoStampRootAttributes()behavior introduced in PR #297. This fixes a bug where templates with onlydj-view(no explicitdj-root) would fail to render correctly. (#300) - Client-side autoMount now correctly reads dj-view attribute — Fixed
autoMount()to usegetAttribute('dj-view')instead ofcontainer.dataset.djView. ThedatasetAPI readsdata-*attributes, butdj-viewis not a data attribute, causing the attribute to be missed. (#300) - System check T002 downgraded from WARNING to INFO — Since
dj-rootis now optional and auto-inferred fromdj-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.C012system check) when base or layout templates manually include<script src="{% static 'djust/client.js' %}">. Since the framework auto-injectsclient.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.C010system check warns when Tailwind CDN (cdn.tailwindcss.com) is detected in production templates (DEBUG=False). Provides actionable guidance to compile CSS withdjust_setup_csscommand 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
[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 manualstatic_assignsAPI. Two-layer detection: snapshot comparison for instance attributes,id()reference comparison for computed values (e.g.,@lru_cacheresults). Immutable types (str,int,float,bool,None,bytes,tuple,frozenset) skipdeepcopyin snapshots.
Removed
static_assignsclass 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) Seedocs/website/guides/template-cheatsheet.md. - Authentication & Authorization — Opinionated, framework-enforced auth for LiveViews. View-level
login_requiredandpermission_requiredclass attributes (plusLoginRequiredMixin/PermissionRequiredMixinfor Django-familiar patterns). Custom auth logic viacheck_permissions()hook. Handler-level@permission_required()decorator for protecting individual event handlers. Auth checks run server-side beforemount()and before handler dispatch — no client-side bypass possible. Integrates withdjust_auditcommand (shows auth posture per view) and Django system checks (djust.S005warns on unprotected views with exposed state). - Navigation & URL State —
live_patch()updates URL query params without remount,live_redirect()navigates to a different view over the same WebSocket. Includeshandle_params()callback,live_session()URL routing helper, and client-sidedj-patch/dj-navigatedirectives with popstate handling. (#236) - Presence Tracking — Real-time user presence with
PresenceMixinandPresenceManager. Pluggable backends (in-memory and Redis). IncludesLiveCursorMixinandCursorTrackerfor collaborative live cursor features. (#236) Seedocs/website/guides/presence.md. - Streaming —
StreamingMixinfor real-time partial DOM updates (e.g., LLM token-by-token streaming). Providesstream_to(),stream_insert(),stream_text(),stream_error(),stream_start()/stream_done(), andpush_state(). Batched at ~60fps to prevent flooding. (#236) Seedocs/website/guides/streaming-markdown.md. - File Uploads —
UploadMixinwith binary WebSocket frame protocol for chunked file uploads. Includes progress tracking, magic bytes validation, file size/extension/MIME checking, and client-sidedj-upload/dj-upload-dropdirectives. (#236) Seedocs/website/guides/uploads.md. - JS Hooks —
dj-hookattribute for client-side JavaScript lifecycle hooks (mounted, updated, destroyed, disconnected, reconnected). (#236) - Model Binding —
dj-modeltwo-way data binding with.lazyand.debounce-Nmodifiers. Server-sideModelBindingMixinwith security field blocklist and type coercion. (#236) Seedocs/website/guides/model-binding.md. - Client Directives —
dj-confirmconfirmation dialogs,dj-targetscoped 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 periodichandle_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) Seedocs/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
TenantMixinandTenantScopedMixinfor views. (#235) dj-pollattribute — Declarative polling for LiveView elements. Adddj-poll="handler_name"to any element to trigger the handler at regular intervals. Configurable viadj-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 usedjango.contrib.auth. Wraps WebSocket routes with session middleware only (no auth required). UpdatedC005system check to recognize bothAuthMiddlewareStackandDjustMiddlewareStack. (#265)- System check
C006— Warns whendaphneis inINSTALLED_APPSbutwhitenoisemiddleware is missing. (#259) startproject/startapp/newCLI commands —python -m djust new myappcreates a full project with optional features (--with-auth,--with-db,--with-presence,--with-streaming,--from-schema). Legacystartprojectandstartappcommands also available. (#266)djust mcp installCLI command — Automates MCP server setup for Claude Code, Cursor, and Windsurf. Triesclaude mcp addfirst (canonical for Claude Code), falls back to writing.mcp.jsondirectly. Merges with existing config, backs up malformed files, idempotent. Seedocs/website/guides/mcp-server.md.- Simplified root element —
dj-viewis now the only required attribute on LiveView container elements. The client auto-stampsdj-rootanddj-liveview-rootat init time. Old three-attribute format still works. (#258) - Model
.pkin templates —{{ model.pk }}now works in Rust-rendered templates. Model serialization includes apkkey with the native primary key value. (#262) Seedocs/website/guides/template-cheatsheet.md. - Better Error Messages — Improved error messages for common LiveView event handler mistakes (missing
@event_handler, wrong method signature). (#248) Seedocs/website/guides/flash-messages.md. LiveViewSmokeTestmixin — Automated smoke and fuzz testing for LiveView classes. (#251)- MCP server —
python manage.py djust_mcpstarts a Model Context Protocol server for AI assistant integration. Provides framework introspection, system checks, scaffolding, and validation tools. Used bydjust mcp installto configure Claude Code, Cursor, and Windsurf. Seedocs/website/guides/mcp-server.md. djust_auditmanagement command — Security audit showing auth posture, exposed state, and handler signatures per view.djust_checkmanagement command — Django system checks for project validation. Gains--fixflag for safe auto-fixes and--format jsonfor enhanced output with fix hints.djust_schemamanagement command — Extract and generate Django models from JSON schema files. Seedocs/guides/djust-audit.md.djust_ai_contextmanagement command — Generate AI-focused context files for LLM integrations. Seedocs/guides/djust-audit.md.- AI documentation —
docs/ai/with focused guides for events, forms, JIT, lifecycle, security, and templates.docs/llms.txtanddocs/llms-full.txtfor LLM context. - Auto-build client.js from src/ modules — Pre-commit hook runs
build-client.shwhensrc/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-sideextractTypedParams()now strips thedj_prefix fromdata-dj-*attributes.data-dj-preset="dark"sends{preset: "dark"}instead of{dj_preset: "dark"}. Update handler parameter names accordingly:dj_foo→foo. - State Backends — Enhanced with tenant-aware isolation support (
TenantAwareRedisBackend,TenantAwareMemoryBackend).
Performance
- Batched
sync_to_asynccalls — 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 17json.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 resolution —
resolve_context_processors()results cached per settings configuration. Invalidated onsetting_changedsignal. (#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 events —
dj-change,dj-input,dj-blur,dj-focusnow 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
_intentionalDisconnectflag. - 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 Protocol —
post()now accepts the HTTP fallback format where the event name is in theX-Djust-Eventheader and params are flat in the body JSON. (#255) - Debug panel HTTP-only mode — POST responses include
_debugpayload whenDEBUG=True, enabling the debug panel in HTTP-only mode. (#267) - Silent LiveView config failures — Client JS now shows helpful
console.errorwhen no LiveView containers are found. Added system checkV005for modules not inLIVEVIEW_ALLOWED_MODULES. (#257) - HTTP-only mode session state on GET —
get()now saves view state to the session immediately whenuse_websocket: False. (#264) use_websocket: Falseclient-side enforcement — Setting now actually prevents WebSocket connections. (#260)- DOM morphing preserves event listeners —
html_updatenow uses morphdom-style DOM diffing instead ofinnerHTML. (#236) - Textarea newlines preserved — Template whitespace stripping no longer collapses newlines inside
<textarea>elements. (#236) - PresenceMixin crash without auth —
track_presence()now checks forrequest.userbefore accessing it. (#236) _skip_rendersupport in server_push —server_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_dfordata-dj-idresolution. (#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_idinstead of path-based traversal. (#216) - Diff engine keyed+unkeyed interleaving — Emits
MoveChildpatches for unkeyed element children in keyed contexts. (#219) - Text node targeting after keyed moves —
SetTextpatches carrydjust_idwhen available;sync_idspropagates IDs to text nodes. (#221) - Tag registry test pollution —
clear_tag_handlers()now restores built-in handlers in teardown. (#261)
Security
- HTTP POST handler dispatch gating —
post()now enforces the same security model as the WebSocket path: only@event_handler-decorated methods can be invoked. Validates event names withis_safe_event_name()to block dunders and private methods. - Auto-escaping in Rust template engine —
SafeStringvalues propagated to Rust for proper auto-escaping. - HTML-escaped
urlizeandunordered_listfilters — Both filters now escape their output to prevent XSS. (#254) - Template tag XSS prevention — All PWA template tags now use
format_html()andescape()instead ofmark_safe()with f-string interpolation. - Sync endpoint hardening — Removed
@csrf_exemptfromsync_endpoint_view. Added authentication requirement, payload validation, and safe field extraction. - Silent exception elimination — All
except: passpatterns replaced with appropriate logging calls. - Production JS hardened — All
console.logcalls guarded behinddjustDebugflag.
Removed
_allowed_eventsclass 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_handlerdecorator.
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) becauseSetAttributepatches updated thedj-clickDOM attribute but not the listener closure. Event listeners now re-parsedj-*attributes from the DOM at event time. Also setsdj-*as DOM attributes increateNodeFromVNodeand marks elements as bound to prevent duplicate listeners. (#205) - VDOM: Non-breaking Space Text Nodes Stripped — Rust parser stripped
-only text nodes (used in syntax highlighting) becausechar::is_whitespace()includes U+00A0. Now preserves\u00A0text nodes in parser,to_html(), and client-side path traversal. Also addssync_ids()to prevent ID drift between server VDOM and client DOM after diffing, and 4-phase patch ordering matching Rust'sapply_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 thecsrftokencookie. (#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 @propertyattributes are now serialized via Rust→Python codegen fallback when Rust can't access themlist[Model]context values (not just QuerySets) now receive full JIT optimization withselect_related/prefetch_related- Nested dicts containing Model/QuerySet values are now deep-serialized recursively
_djust_annotationsmodel 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.fieldinside{% for item in items %})
- M2M
{% include %}After Cache Restore —template_dirswas not included in msgpack serialization ofRustLiveView. After a cache hit, the restored view had empty search paths, causing{% include %}tags to fail with "Template not found". Now callsset_template_dirs()on both WebSocket and HTTP cache-hit paths.- VDOM Replace Sibling Grouping — Fixed
data-djust-replaceinserting children into wrong parent when the replace container has siblings.groupPatchesByParent()now uses the full path for child-operation patches, andgroupConsecutiveInserts()checks parent identity before batching. (#144) - VDOM Replace Child Removal — Fixed
data-djust-replacenot 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
messagesvariable). - VDOM Keyed Diff Insert Ordering — Fixed
apply_patchesfor keyed diff insert ordering where items were inserted in the wrong position. (#154) - VDOM MoveChild Resolution — Fixed
MoveChildinapply_patchby resolving children viadjust_idinstead of index. (#150) - Debug Toolbar: Received WebSocket Messages Not Captured — Network tab now captures both sent and received WebSocket messages by intercepting the
onmessageproperty setter (not justaddEventListener). (#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.liveViewhook. (#188) - Debug Panel: Handler Discovery, Auto-loading, Tab Crashes — Handler discovery now finds all public methods;
debug-panel.jsauto-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_debugfield with updated variables, handlers, patches, and performance metrics. (#191) Seedocs/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
DjustDebugPanelclass. (#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
@eventdecorator alias — The@eventshorthand is deprecated in favor of@event_handler.@eventwill be removed in v0.3.0. A deprecation warning is emitted at import time. (#141)
Changed
- Internal: LiveView Mixin Extraction — Refactored monolithic
live_view.pyinto focused mixins:RequestMixin,ContextMixin,JITMixin,TemplateMixin,RustBridgeMixin,ComponentMixin,LifecycleMixin. No public API changes. (#130) - Internal: Module Splits — Split
client.jsinto source modules with concat build, extractedwebsocket_utils.py,session_utils.py,serialization.py, splitstate_backend.pyintostate_backendspackage, splittemplate_backend.pyintotemplatepackage. (#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 beforegetattr() @event_handlerdecorator allowlist — only methods decorated with@event_handler(or listed in_allowed_events) are callable via WebSocket. Configurable viaevent_securitysetting ("strict"default,"warn","open")- Server-side rate limiting — per-connection token bucket algorithm with configurable rate/burst. Per-handler
@rate_limitdecorator for expensive operations. Automatic disconnect after repeated violations (close code 4429) - Per-IP connection limit — process-level
IPConnectionTrackerenforces a maximum number of concurrent WebSocket connections per IP (default: 10) and a reconnection cooldown after rate-limit disconnects (default: 5 seconds). Configurable viamax_connections_per_ipandreconnect_cooldowninrate_limitsettings. SupportsX-Forwarded-Forheader for proxied deployments. (#108, #121) - Message size limit — 64KB default (
max_message_sizesetting)
- Event name guard — regex pattern filter (
Documentation
- Added migration guide for
@event_handlerdecorator requirement and strict mode upgrade path (#105, #122) - Added
@event_handlerdecorator 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_eventsclass attribute — escape hatch for bulk allowlisting without decorating each methodLIVEVIEW_CONFIGsettings:event_security,rate_limit(includingmax_connections_per_ip,reconnect_cooldown),max_message_size
0.2.0 - 2026-01-28
Added
-
Template
and/or/inOperators -{% if %}conditions now supportand,or, andinboolean/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
innerHTMLwhen content was pre-rendered via HTTP GET. Instead,data-dj-idattributes 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_vdomDjango 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:@click→dj-click,@input→dj-input,@change→dj-change,@submit→dj-submit,@blur→dj-blur,@focus→dj-focus,@keydown→dj-keydown,@keyup→dj-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. Usedjust.Componentwhich now imports fromcomponents/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_decoratorsdict instead. (#89) -
BREAKING: Data Attributes Renamed - Standardized data attribute naming for consistency:
dj-liveview-root→dj-rootdata-live-view→dj-viewdata-live-lazy→dj-lazydata-dj→data-dj-id(#89)
-
BREAKING: WebSocket Message Types - Renamed message types for consistency:
connected→connectmounted→mounthotreload.message→hotreload(#89)
Added
-
LiveComponent Methods - Added missing methods to
LiveComponent:_set_parent_callback(),send_parent(),unmount(). (#89) -
Inline Template Support -
LiveComponentnow supports inlinetemplateattribute for template strings, in addition totemplate_namefor file-based templates. (#89)See
docs/website/guides/template-cheatsheet.md. -
Form Components Export -
ForeignKeySelectandManyToManySelectare now exported fromdjust.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.,
contentinsidebody), 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 forwithclause (pass variables) andonlykeyword (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 ofdj-click="handler" data-value="arg". Supports strings, numbers, booleans, null, and multiple arguments. (#67)
Fixed
- Async Event Handlers: WebSocket consumer now properly supports
async defevent 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) -
urlencodeFilter: Added theurlencodefilter 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-lazyattribute with modes:viewport(default),click,hover, oridle. 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_contentinsidecontent). 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 likeGOOGLE_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