Skip to content
docs.djust.org

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

[0.7.0rc1] - 2026-04-24

Added

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

[0.6.1rc1] - 2026-04-24

Added

  • Time-Travel Debugging (v0.6.1) — dev-only debug-panel tab that records a state snapshot around every @event_handler dispatch (state_before / state_after), then lets developers scrub back through the timeline and jump to any past state. The server restores the snapshot via safe_setattr and re-renders through the normal VDOM patch pipeline. Opt-in per view (time_travel_enabled = True on the LiveView subclass); zero cost when disabled. Gated on DEBUG=True at the WebSocket consumer so production clients can't coerce a jump even if the class attr is left on. Per-view bounded ring buffer (default 100 events, configurable via LIVEVIEW_CONFIG["time_travel_max_events"]). New module djust.time_travel (EventSnapshot, TimeTravelBuffer, record_event_start, record_event_end, restore_snapshot). New inbound WS frame time_travel_jump + outbound time_travel_state ack, plus time_travel_event frames pushed after every recorded snapshot so the debug panel timeline populates incrementally (client CustomEvent djust:time-travel-event). Instrumentation wraps all three dispatch branches (actor, component, view handler) and records permission-denied / validation-failed events with an error marker. Component events record against the parent view in Phase 1 (full component-level time travel is a v0.6.2 follow-up). Ghost-attr cleanup in restore_snapshot removes public attributes not present in the target snapshot, so restoring {a:1} over {a:5, b:10} leaves {a:1} rather than {a:1, b:10}. New client events djust:time-travel-state and djust:time-travel-event (CustomEvents). New system checks djust.C501 (info — global switch on) and djust.C502 (error — non-positive time_travel_max_events). Beyond Redux DevTools: server-side so no client state store; beyond Phoenix LiveView's debug tools which are telemetry-only. See docs/website/guides/time-travel-debugging.md.
  • Streaming Initial Render (v0.6.1, Phase 1) — opt-in chunked HTTP response for LiveView GET requests. Setting streaming_render = True on a LiveView class returns a StreamingHttpResponse that flushes the page in three chunks: shell-open (everything before <div dj-root>), main content (the <div dj-root>...</div> body), and shell-close (</body></html> + trailing markup). Phase 1 is transport-layer only — the server fully assembles the rendered HTML before streaming it; the benefit is HTTP/1.1 chunked transfer (no Content-Length, earlier TCP flush, compatibility with chunk-relaying proxies, avoiding gzip-buffer stalls). True server-side render overlap (browser parses shell while server computes main content) arrives with Phase 2 (v0.6.2) alongside lazy-child streaming via {% live_render lazy=True %}. No client-side code changes; opt-in per view, backward-compatible default. Response emits X-Djust-Streaming: 1 for observability and omits Content-Length. See docs/website/guides/streaming-render.md.
  • Hot View Replacement (HVR, v0.6.1) — state-preserving Python code reload in development. When a LiveView module changes on disk, the dev server importlib.reload()s the module and swaps __class__ in place on every live instance of the changed class, then re-renders via the existing VDOM diff path. Users keep form input, counter values, active tab, and scroll position — React Fast Refresh parity for djust. Gated on DEBUG=True + LIVEVIEW_CONFIG["hvr_enabled"] (default True). Falls back to full reload on a conservative state-compat heuristic (removed handlers, changed handler signatures, or slot layout drift). New system check djust.C401 warns when HVR is enabled but watchdog is not installed. New client event djust:hvr-applied (CustomEvent). Zero cost in production.

[0.6.0rc1] - 2026-04-23

Documentation

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

Changed

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

Added

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

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

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

Changed

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

Security

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

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

    User-facing API

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

    Wire protocol

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

    Client-side

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

    Security

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

    Testing (32 Python + 20 JSDOM + 6 integration)

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

    Documentation

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

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

[0.5.7rc1] - 2026-04-23

Added

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

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

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

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

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

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

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

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

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

Fixed

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

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

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

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

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

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

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

Tests

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

Fixed

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

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

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

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

[0.5.6rc1] - 2026-04-23

BREAKING CHANGES

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

Added

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

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

Fixed

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

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

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

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

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

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

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

Security

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

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

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

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

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

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

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

Changed

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

[0.5.4rc1] - 2026-04-22

Fixed

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

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

Added

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

[0.5.3rc1] - 2026-04-22

Added

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

[0.5.2rc1] - 2026-04-22

Added

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

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

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

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

Changed

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

Fixed

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

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

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

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

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

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

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

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

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

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

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

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

  • UploadWriter close() return validated as JSON-serializable (#825) — Non-JSON returns caught at finalize time and abort the upload cleanly. (python/djust/uploads.py)

  • BufferedUploadWriter write_chunk() after close() raises (#823)_finalized flag now actively enforced; repeated close() is idempotent. (python/djust/uploads.py)

  • Upload-manager drops trailing chunks silently after abort (#824, partial) — Fast-path at DEBUG log; writer.abort() called once. (python/djust/uploads.py)

  • Morph-path honors dj-ignore-attrs (#815) — The VDOM morph loop at python/djust/static/djust/src/12-vdom-patch.js:746-758 previously stripped and overwrote attributes without consulting djust.isIgnoredAttr. Attributes listed in dj-ignore-attrs would survive individual SetAttr patches (the guard added in PR #814) but could still get wiped during a full-element morph. The morph-path remove-loop and set-loop both now skip ignored attribute names. Two regression tests in tests/js/ignore_attrs.test.js cover remove-loop and set-loop preservation. (python/djust/static/djust/src/12-vdom-patch.js)

Changed

  • dj-ignore-attrs CSV empty-token hardening (#816)isIgnoredAttr now skips empty tokens produced by double-comma ("open,,close") or trailing-comma ("open,") CSV values, and rejects empty attribute-name queries. Previously those edge cases could accidentally match an empty attribute name. Four regression tests in tests/js/ignore_attrs.test.js cover empty string, whitespace-only, double comma, and trailing comma. (python/djust/static/djust/src/31-ignore-attrs.js)

Added

  • djust_typecheck{% firstof %} / {% cycle %} / {% blocktrans with %} tag support (#850) — The extractor now captures positional context-variable references in {% firstof a b c %} and {% cycle a b c %} (string literals and as <name> suffixes are correctly ignored), and the with x=expr (and count x=expr) clauses of {% blocktrans %} / {% blocktranslate %} produce both the template-local binding (x) and the reference (expr). Eliminates a class of false positives (blocktrans locals) and false negatives (firstof/cycle args). (python/djust/management/commands/djust_typecheck.py)

Changed

  • djust_typecheck — walk MRO for parent-class self.foo = ... assigns (#851)_extract_context_keys_from_ast now iterates cls.__mro__ (skipping djust.*, djust_*, django.*, rest_framework.*, and builtins), so a child view that relies on attributes set in a parent mount() no longer produces spurious "unresolved" reports. The filter drops Django's View / namespace-framework attrs (request, head, kwargs, args) that would otherwise surface from the base class. (python/djust/management/commands/djust_typecheck.py)

  • Shared class-introspection helpers (#852)_walk_subclasses, _is_user_class, and _app_label_for_class are now a single source of truth in the new djust.management._introspect module; djust_audit and djust_typecheck both import from it. No behavior change; purely a refactor to prevent drift as the set of management commands grows. _introspect.walk_subclasses also gained cycle-safety (diamond-inheritance deduplication) which the old recursive implementation lacked. (python/djust/management/_introspect.py, python/djust/management/commands/djust_audit.py, python/djust/management/commands/djust_typecheck.py)

  • Service worker + main-only middleware follow-ups to PR #826 (closes #827/#828/#829/#830)

    • #828DjustMainOnlyMiddleware now early-returns on responses with status_code >= 400. Error pages render full-page layouts (status message, "go back" link, etc.); trimming them to <main> would strip that context from shell-navigation clients. Regression tests cover 4xx and 5xx.
    • #830 — HTML response detection widened to include application/xhtml+xml in addition to text/html. Charset and boundary suffixes (text/html; charset=utf-8; boundary=xyz) are stripped before matching. Defensive test confirms application/rss+xml is still treated as non-HTML.
    • #829djust.registerServiceWorker() is now idempotent. A second call returns the cached registration promise without re-running initInstantShell / initReconnectionBridge, so drain listeners and the WS sendMessage patch are applied at most once. Previous behavior caused buffered replays to double on repeat init.
    • #827 — Documented the <script>-inside-<main> limitation of the instant-shell innerHTML swap at the top of 33-sw-registration.js. The doc block was also corrected: dj-click/dj-submit/etc. work through document-level event delegation (not MutationObserver), and dj-hook now explicitly re-runs via a djust.reinitAfterDOMUpdate(placeholder) call after the swap — dj-hook content inside <main> actually works post-swap as a result (previous implementation silently skipped hook re-binding).

    Tests: 9 → 13 Python cases in tests/unit/test_main_only_middleware.py, +2 JS cases in tests/js/service_worker.test.js (12 total). (python/djust/middleware.py, python/djust/static/djust/src/33-sw-registration.js)

[0.5.1rc4] - 2026-04-22

Added

  • Transport-conditional API returns — api_response() convention + @event_handler(expose_api=True, serialize=...) override (v0.5.1 P2 follow-up to ADR-008) — Handlers serving both WebSocket and HTTP API callers often have split needs: WS only wants state mutation (VDOM renders the UI), HTTP wants actual data in the response. Serializing query results on every WS keystroke is wasteful. Resolved with three-tier resolution on the HTTP path (zero overhead on WS): (1) per-handler @event_handler(expose_api=True, serialize=<callable-or-str>) wins when set; (2) otherwise the view's api_response(self) method is called (the DRY convention — one method, many handlers); (3) otherwise the handler's return value passes through unchanged. serialize= accepts a callable (arity-detected: fn() / fn(view) / fn(view, result)) or a method-name string resolved against the view at dispatch time. Async serializers and async api_response() are both awaited. serialize= without expose_api=True raises TypeError at decoration. Missing method or serializer exception → 500 serialize_error (details logged server-side only); PermissionDenied raised from either path surfaces as 403 (not 500). The self._api_request = True flag is set by dispatch before mount() runs so mount can branch on transport; it is retained as an escape hatch for code that needs transport awareness without the decorator plumbing. 28 tests in python/djust/tests/test_api_response.py cover unit-level resolution (passthrough, convention, per-handler override, arity detection, async paths, MRO-provided api_response, shadowed non-callable api_response, invalid spec types, staticmethod-via-string, callable class instances) and end-to-end dispatch integration (including PermissionDenied surfacing as 403 and the mount-time flag availability). Full guide in docs/website/guides/http-api.md under "Transport-conditional returns". (python/djust/decorators.py, python/djust/api/dispatch.py)

[0.5.1rc3] - 2026-04-21

Added

  • LiveView testing utilities (v0.5.1 P2) — Seven new methods on LiveViewTestClient for Phoenix LiveViewTest parity: assert_push_event(event_name, params=None) verifies a handler queued a client-bound push event (payload match is subset-based so tests stay resilient to later payload additions); assert_patch(path=None, params=None) / assert_redirect(path=None, params=None) assert live_patch / live_redirect calls; render_async() drains pending start_async / assign_async tasks synchronously so subsequent assertions see their results; follow_redirect() resolves the queued redirect via Django's URL router and returns a new test client mounted on the destination view; assert_stream_insert(stream_name, item=None) verifies stream operations (item subset-match for dicts); trigger_info(message) synthetically delivers a handle_info message so pubsub / pg_notify handlers can be tested without real backend wiring. Full user-facing guide at docs/website/guides/testing.md. 21 new test cases. (python/djust/testing.py)
  • dj-dialog — native <dialog> modal integration (v0.5.1 P2) — Declarative opt-in for the HTML <dialog> element's built-in modal behavior. Mark a <dialog> with dj-dialog="open" to call showModal() (backdrop, focus-trap, and Escape handling all browser-native); set dj-dialog="close" to call close(). A document-level MutationObserver watches for attribute changes and DOM insertions so VDOM morphs that swap dj-dialog work automatically without per-element re-registration. Idempotent — re-asserting "open" on an already-open dialog is a no-op; gracefully ignores non-<dialog> elements carrying the attribute. ~80 LOC JS in python/djust/static/djust/src/35-dj-dialog.js. 8 JSDOM tests in tests/js/dj_dialog.test.js.
  • Type-safe template validation — manage.py djust_typecheck (v0.5.1 P2, differentiator) — Static analysis that reads every LiveView template, extracts every variable and tag reference, and reports names not covered by the view's declared context. "Declared context" is the union of public class attributes, self.foo = ... assignments anywhere in the class (AST-extracted — not run), @property methods, literal-dict keys returned from get_context_data, template-local bindings ({% for %} / {% with %} / {% inputs_for as %}), framework built-ins (user, request, csrf_token, forloop, djust, etc.), and anything listed in settings.DJUST_TEMPLATE_GLOBALS. Silencing: per-template pragma ({# djust_typecheck: noqa name1, name2 #}), per-view strict_context = True opt-in, or the project-wide globals setting. Flags: --json, --strict, --app, --view. Neither Phoenix nor React catches template-variable typos statically without an external type system — this is a genuine djust differentiator. 14 tests in python/djust/tests/test_djust_typecheck.py. Full guide at docs/website/guides/typecheck.md. (python/djust/management/commands/djust_typecheck.py)
  • Dev-mode error overlay (v0.5.1 P2) — Next.js/Vite-style full-screen error panel that renders in the browser whenever a LiveView handler raises an exception and Django DEBUG=True. Displays the error message, the event that triggered the handler, the server-sent Python traceback, an optional hint, and validation details when present. Dismissal: Escape key, close button, or backdrop click. A second error replaces the current panel rather than stacking. All field values HTML-escaped to prevent traceback injection. Gated on window.DEBUG_MODE — production builds render nothing (Django also strips traceback / debug_detail / hint from the error frame in non-DEBUG mode, so there's nothing to leak). Exposes window.djustErrorOverlay.show(detail) / .dismiss() for manual invocation from devtools. 10 JSDOM tests in tests/js/error_overlay.test.js. Full guide at docs/website/guides/error-overlay.md. (python/djust/static/djust/src/36-error-overlay.js)
  • Nested formset helpers — {% inputs_for %} + FormSetHelpersMixin (v0.5.1 P2) — djust-native support for Django formset / inline-formset patterns. Template side: {% inputs_for formset as form %}...{% endinputs_for %} iterates any BaseFormSet and exposes each bound child form with its per-row prefix intact so rendered inputs submit under the correct Django-expected names; loop metadata (inputs_for_loop.counter, .counter0, .first, .last) mirrors the {% for %} conventions. Server side: djust.formsets.add_row(cls, data=..., prefix=...) and remove_row(cls, row_prefix, data=..., prefix=...) handle management-form bookkeeping — add_row bumps TOTAL_FORMS (capped at max_num when set, absolute_max otherwise) and preserves existing row data; remove_row writes the standard DELETE=on flag so formset.deleted_forms picks it up on save(). FormSetHelpersMixin wires pre-baked add_row / remove_row event handlers to a formset_classes = {"addresses": AddressFormSet} declaration, with the formset name doubling as the prefix so multiple formsets on one view don't collide on management-form keys. Fails loud if mount() forgets to initialize self._formset_data. 16 tests in python/djust/tests/test_formsets.py. (python/djust/formsets.py, python/djust/templatetags/djust_formsets.py)

[0.5.1rc2] - 2026-04-21

Added

  • Scaffold CSS — reusable layout/utility pack in djust.themingdjust_theming/static/djust_theming/css/scaffold.css gains ~729 lines of framework-generic scaffold covering typography, responsive grid utilities (.grid-2/3/4), hero section, flash messages (Django + LiveView), accessibility utilities (.sr-only), extended layout helpers (.flex-center, .content-narrow/-wide), stat-display variants, auth layout, live indicator dot, card-accent variants, code blocks, noise texture overlay, shared nav links, dashboard/centered grids, and the full data-layout switching system (sidebar, topbar, dashboard, centered, sidebar-topbar). All new rules use CSS-variable fallbacks so the scaffold works without a loaded theme; no hardcoded hex colors; .container max-width now reads var(--container-width, 1200px). Pure-CSS addition — no Python/JS/test behavior changes. (PR #836)

Fixed

  • All 82 pre-existing test failures resolved (PR #841) — The make test baseline went from 2135 passed, 61 failed, 21 errors (which had blocked normal merges for the entire v0.5.1 milestone and forced --admin on every PR) to 2219 passed, 0 failed, 0 errors. Four fix clusters:
    • Test-infrastructure shims (64 fixes) — added tests/gallery_test_urls.py and tests/test_critical_css.py URL-conf shims that theming tests reference via @override_settings(ROOT_URLCONF=...) but were never created; added mcp[cli]>=1.2.0; python_version >= '3.10' to dev deps so djust.mcp server tests stop throwing ModuleNotFoundError.
    • Stale @layer test expectations (4 fixes) — several theming CSS files (components.css, layouts.css, pages.css, critical-CSS generator) were intentionally unwrapped from @layer blocks for specificity reasons (documented in file headers); updated tests to match the current design using @layer NAME { block-syntax regex rather than substring match.
    • Real code bugs (3 fixes) — ocean_deep preset's internal name was "ocean" while its registry key was "ocean_deep"; one stray text-align: left in components.css .tp-select-option broke RTL support (changed to text-align: start); and the CSS prefix generator's hand-maintained _COMPONENT_CLASSES list had drifted from components.css.btn-edit, .btn-remove, .avatar, .breadcrumb, .dropdown and many more weren't being prefixed when a custom css_prefix was set. Replaced with auto-extraction via regex over the static file; stays in sync automatically.
    • Stale test assumption (1 fix) — test_list_same_content_no_render encoded a wrong assumption about _snapshot_assigns (identity-based by design); rewrote to match the documented contract.
  • CSS prefix generator hardening — Auto-extraction regex gained a negative lookbehind (?<![\w]) to prevent capturing domain fragments inside data-URIs (previously .org and .w3 in http://www.w3.org/2000/svg were mis-captured as class selectors, producing http://www.dj-w3.dj-org/2000/svg under prefix); compound state-class chains like .wizard-step.completed now correctly leave the trailing state class unprefixed (JS toggles state classes by bare name, so they must NOT get the prefix). Two new regression tests (test_data_uri_domains_are_not_mis_prefixed, test_compound_state_classes_stay_unprefixed) lock both in.

Changed

  • ROADMAP.md audit correction — Five entries marked as "v0.5.1 Not started" were actually shipped earlier: djust-theming fold (v0.5.0 PR #772), WizardMixin (PR #632), Error boundaries (v0.5.0 PR #773), and dj-lazy lazy LiveView hydration (PR #54). All marked with strikethrough + ✅ and a shipped-in PR pointer. Real v0.5.1 remainder after audit: LiveView testing utilities, type-safe template validation, error overlay, inputs_for nested formsets, native <dialog> (5 items instead of 8).

[0.5.1rc1] - 2026-04-21

Added

  • Form & submit polish batch (v0.5.1 P2) — Three related form-UX primitives:

    • dj-no-submit="enter" — Prevent Enter-key form submission from text inputs. Fixes the #1 form UX annoyance where pressing Enter to confirm a field accidentally submits the whole form. Textareas (multi-line input), submit-button clicks, and modified keys (Shift+Enter, Ctrl+Enter) are unaffected. Supports comma-separated modes (currently only "enter") for future expansion. Document-level keydown listener — DOM morphs don't need re-registration. (python/djust/static/djust/src/34-form-polish.js)
    • dj-trigger-action + self.trigger_submit(selector) — Bridge successful djust validation to a native HTML form POST. Essential for OAuth redirects, payment gateway handoffs, and anywhere the final step needs a real browser POST. The server calls self.trigger_submit("#form-id") after validation passes; the client receives the push event, verifies the target form carries dj-trigger-action (explicit opt-in — refusal is logged in debug mode), and calls the form's native .submit(). (python/djust/mixins/push_events.py, python/djust/static/djust/src/34-form-polish.js)
    • dj-loading="event_name" shorthand — Declarative scoped loading indicator: <div dj-loading="search">Searching...</div> shows only while the search event is in-flight. Previously required combining dj-loading.show + dj-loading.for="event_name" with an inline style="display:none". The shorthand auto-hides the element on register (no inline style required) and treats the attribute value as both the event-scope and the implicit .show trigger. Coexists with the existing dj-loading.* modifier family. (python/djust/static/djust/src/10-loading-states.js)

    Tests: 11 JS test cases in tests/js/form_polish.test.js covering every happy path and failure mode; 4 Python tests in python/djust/tests/test_trigger_submit.py locking in the push-event shape. Client.js: 35 → 36 source modules (+~120 LOC JS, +~30 LOC Python). Scoped scoped-loading dj-loading="event" implementation reuses existing globalLoadingManager infrastructure — no duplication.

  • State & computation primitives batch (v0.5.1 P2) — Four small related primitives for derived state, dirty tracking, stable IDs, and cross-component context sharing:

    • Memoized @computed("dep1", "dep2")@computed now accepts an optional tuple of dependency attribute names. When given, the value is cached on the instance and only recomputed when any dep's identity or shallow content fingerprint changes (id + length + key subset matching _snapshot_assigns semantics). Plain @computed (no args) retains property semantics — recomputes every access. React useMemo equivalent. (python/djust/decorators.py)
    • Automatic dirty tracking — self.is_dirty / self.changed_fields / self.mark_clean() — Track which public view attributes have changed since a baseline captured after mount(). changed_fields returns a set of attr names that differ from the baseline; is_dirty is bool(changed_fields); mark_clean() resets the baseline (call after a successful save). Use cases: "unsaved changes" warnings (beforeunload), conditional save buttons, optimized handle_event that skips work when nothing changed. Respects static_assigns and ignores private attrs. The WebSocket consumer and the HTTP API dispatch view both capture the baseline after mount. (python/djust/live_view.py, python/djust/websocket.py, python/djust/api/dispatch.py)
    • Stable self.unique_id(suffix="") — React 19 useId equivalent. Returns a deterministic per-view ID stable across renders of the same logical position. Useful for aria-labelledby, form field IDs, and any element that needs a consistent identifier across re-renders. Format: djust-<viewslug>-<n>[-<suffix>]. Counter resets via reset_unique_ids() at render boundaries. (python/djust/live_view.py)
    • Component context sharing — self.provide_context(key, value) / self.consume_context(key, default=None) — React Context API equivalent. A parent view or component exposes a value under key; descendants look it up with consume_context, walking the _djust_context_parent chain. Scoped per render tree; clear_context_providers() resets. (python/djust/live_view.py)
  • Auto-generated HTTP API from @event_handler (v0.5.1 P1 HEADLINE, ADR-008) — Opt-in @event_handler(expose_api=True) exposes a handler at POST /djust/api/<view_slug>/<handler_name>/ with an auto-generated OpenAPI 3.1 schema served at /djust/api/openapi.json. Unlocks non-browser callers (mobile, S2S, CLI, AI agents) without duplicating business logic — the HTTP transport is a thin adapter over the existing handler pipeline, reusing validate_handler_params(), check_view_auth(), check_handler_permission(), and the same _snapshot_assigns() / _compute_changed_keys() diff machinery the WebSocket path uses. One stack, one truth (manifesto #4). New package djust.api with DjustAPIDispatchView (dispatch view), api_patterns() (URL factory), OpenAPISchemaView (schema endpoint), SessionAuth + pluggable BaseAuth protocol (auth classes may opt out of CSRF via csrf_exempt = True), and a registry that walks LiveView subclasses with exposed handlers. LiveView gains two read-only contract attributes: api_name (stable URL slug) and api_auth_classes (auth class list). Response shape mirrors the WS assigns-diff: {"result": <return>, "assigns": {<changed public attrs>}}. Error shapes are structured with error / message / details — 400 validation, 401 unauth, 403 denied or CSRF fail, 404 unknown view/handler or handler not expose_api=True, 429 rate limit, 500 handler exception (exception messages logged server-side only, never leaked to the client). Rate limiting: HTTP uses a process-level LRU-capped token bucket keyed on (caller, handler_name) honoring the handler's @rate_limit settings; WebSocket continues to use its per-connection ConnectionRateLimiter. The two transports share rate/burst values but separate bucket storage — a caller using both draws from both independently (a shared-bucket refactor is tracked as a follow-up). manage.py djust_audit now lists every expose_api=True handler and flags any missing @permission_required — treat an exposed handler like @csrf_exempt. Out of scope per ADR-008: streaming responses, GraphQL batching, first-party token auth, Swagger UI hosting, per-handler URL customization. Full guide at docs/website/guides/http-api.md. (python/djust/api/, python/djust/decorators.py, python/djust/live_view.py, python/djust/management/commands/djust_audit.py)

  • Service worker core improvements — instant page shell + WebSocket reconnection bridge (v0.5.0 P3, opt-in) — Two independent SW features that close the v0.5.0 milestone. Both are OFF by default; users opt in explicitly via djust.registerServiceWorker({ instantShell: true, reconnectionBridge: true }) from their own init code. No auto-registration.

    • Instant page shell. The SW caches the first navigation's response split into a "shell" (everything outside <main>) and "main" (inside). Subsequent navigations serve the cached shell immediately with a <main data-djust-shell-placeholder="1"> placeholder; the client then fetches the current URL with X-Djust-Main-Only: 1 and swaps in the fresh <main> contents. Shell/main split uses a single non-greedy regex — nested <main> inside HTML comments or </main> inside CDATA are documented limitations (full HTML parser deferred). Server side honors the header via the new djust.middleware.DjustMainOnlyMiddleware, which extracts the first <main>…</main> inner HTML, updates Content-Length, and stamps X-Djust-Main-Only-Response: 1. The middleware only touches HTML responses; JSON / binary / streaming responses pass through unchanged. Ordering-safe — it can sit anywhere in MIDDLEWARE that sees the rendered response.
    • WebSocket reconnection bridge. Client-side wraps LiveViewWebSocket.sendMessage so that when ws.readyState !== OPEN the serialized payload is posted to the SW via postMessage({type: 'DJUST_BUFFER', connectionId, payload}) instead of being dropped. The SW stores messages in an in-memory Map keyed by connection id, capped at 50 per connection (oldest dropped). On reconnect the client fires DJUST_DRAIN; the SW returns the buffered payloads and the client replays each via ws.ws.send(). Per-page-load connection ids isolate buffers across tabs. IndexedDB persistence and server-side sequence-dedup replay are deferred to v0.6 (best-effort replay today).
    • Files: python/djust/static/djust/service-worker.js (new, standalone — NOT bundled into client.js), python/djust/static/djust/src/33-sw-registration.js (new, concatenated into client.js), python/djust/middleware.py (new), python/djust/config.py (new service_worker defaults sub-dict), tests in tests/js/service_worker.test.js (10 cases) and tests/unit/test_main_only_middleware.py (7 cases), full guide at docs/website/guides/service-worker.md.
  • UploadWriter — raw upload byte-stream access for direct-to-S3 / GCS streaming (Phoenix 1.0 parity, v0.5.0 P2) — New UploadWriter base class in djust.uploads with an open()write_chunk(bytes)close() -> Any / abort(error) lifecycle, wired into allow_upload(name, writer=MyWriter). When a writer is configured, binary WebSocket chunks are piped straight to the writer without buffering to disk or RAM — zero temp file, zero entry._chunks. Writers are instantiated lazily per upload on the first chunk (so abandoned uploads never open an S3 multipart upload), opened exactly once, fed write_chunk() per client frame, and finalized via close() whose return value is stored on UploadEntry.writer_result and rendered in the upload-state context as {{ entry.writer_result }}. Any failure (open or write_chunk raised, close() raised, size-limit exceeded, client cancelled, WebSocket disconnected via UploadManager.cleanup()) routes through abort(BaseException) with the raw exception so writers can release server-side resources (e.g. AbortMultipartUpload); abort() is wrapped to swallow its own exceptions so a failing S3 cleanup never propagates into the request path. Includes BufferedUploadWriter helper that accumulates client-sent 64 KB chunks until a configurable buffer_threshold (default 5 MB — S3 MPU minimum part size except for the last) and calls on_part(bytes, part_num) so subclasses work with S3-aligned parts without worrying about raw client chunk size. Legacy (no-writer=) disk-buffered path is untouched byte-for-byte — backward compatible. Documented in docs/website/guides/uploads.md with a full S3 multipart example. (python/djust/uploads.py, python/djust/websocket.py)

  • dj-ignore-attrs — per-element client-owned attribute opt-out (Phoenix 1.1 JS.ignore_attributes/1 parity, v0.5.0 P2) — Mark specific HTML attributes as client-owned so VDOM SetAttr patches skip them. <dialog dj-ignore-attrs="open"> prevents the server from resetting the open attribute that the browser manages; <div dj-ignore-attrs="data-lib-state, aria-expanded"> protects third-party JS state. Comma-separated list with whitespace tolerance. The guard sits inside applySinglePatch's case 'SetAttr' after the UNSAFE_KEYS check; the attribute write is skipped entirely (and breaks out of the case) when the element opts out. RemoveAttr is intentionally unaffected. Implementation: globalThis.djust.isIgnoredAttr(el, key) helper (~20 lines JS) plus a three-line check in the patch site. (python/djust/static/djust/src/31-ignore-attrs.js, python/djust/static/djust/src/12-vdom-patch.js)

  • {% colocated_hook %} template tag + runtime extraction (Phoenix 1.1 ColocatedHook parity, v0.5.0 P2) — Write hook JavaScript inline alongside the template that uses it, instead of in a separate file. {% colocated_hook "Chart" %}hook.mounted = function() { renderChart(this.el); };{% endcolocated_hook %} emits a <script type="djust/hook" data-hook="Chart"> tag with a /* COLOCATED HOOK: Chart */ auditor banner. The client runtime walks script[type="djust/hook"] elements on init and after each VDOM morph (reinitAfterDOMUpdate), registers each body as window.djust.hooks[name] via new Function, and marks the script with data-djust-hook-registered="1" so re-scans are idempotent. Optional namespacing via DJUST_CONFIG = {"hook_namespacing": "strict"} prefixes data-hook with <view_module>.<view_qualname> so two views can each define Chart without colliding; per-tag opt-out with {% colocated_hook "X" global %}. Namespacing is OFF by default for compat. Security: the body is template-author JS (same trust level as any other template JS); </script> / </SCRIPT> are escaped in the tag's render() to prevent premature tag close. Apps on strict CSP without 'unsafe-eval' should continue using the traditional registration pattern. (python/djust/static/djust/src/32-colocated-hooks.js, python/djust/templatetags/live_tags.py, python/djust/config.py, docs/website/guides/hooks.md)

  • Database change notifications — PostgreSQL LISTEN/NOTIFY → LiveView push (v0.5.0 P1) — Subscribe LiveViews to Postgres pg_notify channels so database changes push real-time updates to every connected user with zero explicit pub/sub wiring. Three APIs: @notify_on_save(channel="orders") model decorator hooks Django post_save / post_delete and emits NOTIFY <channel>, <json>; self.listen("orders") in mount() subscribes the view (joins a Channels group named djust_db_notify_<channel>); def handle_info(self, message) receives {"type": "db_notify", "channel": ..., "payload": {"pk": ..., "event": "save"|"delete", "model": "app.Model"}} and re-renders via the standard VDOM diff path. A process-wide PostgresNotifyListener owns one dedicated psycopg.AsyncConnection (outside Django's pool — long-lived LISTEN connections don't play nice with pgbouncer transaction pooling) and runs async for notify in conn.notifies():, bridging every NOTIFY into channel_layer.group_send(...). Channel names are strictly validated (^[a-z_][a-z0-9_]{0,62}$) at registration and listen time — load-bearing because Postgres NOTIFY doesn't accept bind parameters for the channel identifier. send_pg_notify(channel, payload) is a public helper for Celery tasks / management commands. Non-postgres backends no-op gracefully (debug-logged); self.listen() raises DatabaseNotificationNotSupported when psycopg or a postgres backend isn't available. Known limitation: notifications emitted while the listener's TCP connection is dropped are lost — listener auto-reconnects with 1s backoff and re-issues LISTEN for all subscribed channels, and WS mount() re-fetch handles the client-side recovery case. Documented in docs/website/guides/database-notifications.md. (python/djust/db/decorators.py, python/djust/db/notifications.py, python/djust/mixins/notifications.py, python/djust/websocket.py)

  • PyO3 getattr fallback for model attribute access (v0.5.0 P1 — Rust template engine parity) — Templates can now reference Django model instances passed through context without manual dict conversion. {{ user.username }} resolves via Python getattr when user is a raw Python object rather than a JSON-serialized dict. Implementation: Python's _sync_state_to_rust() builds a sidecar of non-JSON-friendly context values and forwards them via the new RustLiveView.set_raw_py_values() method; Rust's Context::resolve() tries the normal value-stack path first, then walks getattr on attached PyObjects one segment at a time. PyAttributeError (and any property-descriptor exceptions) are caught — missing attrs render as empty, matching Django's TEMPLATE_STRING_IF_INVALID default. Value stays Serialize-friendly (no Value::PyObject variant); sidecar lives outside the Value enum via Arc<HashMap<String, PyObject>> on Context. (crates/djust_core/src/context.rs, crates/djust_live/src/lib.rs, python/djust/mixins/rust_bridge.py)

  • register_assign_tag_handler() for context-mutating template tags (v0.5.0 P1 — Rust template engine parity) — New tag-handler variety complementing register_tag_handler (emits HTML) and register_block_tag_handler (wraps content). An assign tag's render(args, context) method returns a dict[str, Any] that's merged into the template context for subsequent sibling nodes — no HTML output. Enables {% assign slot var_name %}-style patterns. Supported inside {% for %} loops (per-iteration mutation). Registered via djust._rust.register_assign_tag_handler(name, handler). New Node::AssignTag variant; partial-renderer emits "*" wildcard dep so downstream nodes always re-render on context changes. (crates/djust_templates/src/registry.rs, crates/djust_templates/src/parser.rs, crates/djust_templates/src/renderer.rs)

  • dj-virtual — Virtual / windowed lists with DOM recycling (v0.5.0 P1) — Render only the visible slice of a large list, recycling DOM nodes as the user scrolls. <div dj-virtual="items" dj-virtual-item-height="48" dj-virtual-overscan="5" style="height: 600px; overflow: auto;"> keeps ~visible-plus-overscan children in the DOM even if the pool has 100K entries. Implementation: fixed-height windowing via transform: translateY(...) on an inner shell plus a hidden spacer for scrollbar length, scroll handler batched through requestAnimationFrame, real element identity preserved across scrolls for hook/framework compatibility. Integrates with the VDOM morph pipeline: new containers are picked up by reinitAfterDOMUpdate, and djust.refreshVirtualList(el) is available for explicit repaints. djust.teardownVirtualList(el) disconnects observers for unmounted containers. (python/djust/static/djust/src/29-virtual-list.js)

  • dj-viewport-top / dj-viewport-bottom — Bidirectional infinite scroll (Phoenix 1.0 parity, v0.5.0 P1) — Fire server events when the first or last child of a stream container enters the viewport via IntersectionObserver. <div dj-stream="messages" dj-viewport-top="load_older" dj-viewport-bottom="load_newer" dj-viewport-threshold="0.1">. Once-per-entry firing (matches Phoenix) via a data-dj-viewport-fired sentinel; call djust.resetViewport(container) or replace the sentinel child to re-arm. New server-side stream() limit=N kwarg and stream_prune(name, limit, edge) method emit a stream_prune op that trims children from the opposite edge so chat apps, activity feeds and log viewers can stream bidirectionally without unbounded DOM growth. (python/djust/static/djust/src/30-infinite-scroll.js, python/djust/static/djust/src/17-streaming.js, python/djust/mixins/streams.py)

  • assign_async / AsyncResult (v0.5.0 P1) — High-level async data loading inspired by Phoenix LiveView's assign_async. Call self.assign_async("metrics", self._load_metrics) in mount() (or any event handler); the attribute is set to AsyncResult.pending() immediately, the loader runs via the existing start_async infrastructure, and on completion the attribute becomes AsyncResult.succeeded(result) or AsyncResult.errored(exc). Templates read the three mutually-exclusive states via {% if metrics.loading %}…, {% if metrics.ok %}{{ metrics.result }}…, {% if metrics.failed %}{{ metrics.error }}…. Sync and async def loaders are both supported; multiple calls in the same handler load concurrently. Cancellation piggybacks on cancel_async("assign_async:<name>"). (python/djust/async_result.py, python/djust/mixins/async_work.py)

  • {% dj_suspense %} block tag for template-level loading boundaries (v0.5.0 P1) — Declarative counterpart to assign_async: wrap a section depending on one or more AsyncResult assigns, and the boundary emits a fallback while any are loading, an error div if any failed, or the body once all are ok. Explicit await="metrics,chart" syntax keeps the tag debuggable — no reflection magic. Fallback templates are loaded via Django's template loader; unspecified fallbacks render a minimal spinner. Nested suspense boundaries resolve independently. Registered alongside {% call %} in the Rust template engine — no parser/renderer changes. (python/djust/components/suspense.py, python/djust/components/rust_handlers.py)

  • Function components via @component decorator (v0.5.0 P1 batch) — Stateless Python render functions registerable as template-invokable components. @component def button(assigns): ... is callable from templates via {% call "button" variant="primary" %}Go{% endcall %} (with {% component %} as a synonymous alias). Closes the middle ground between raw HTML and full LiveComponent classes for the ~80% of UI pieces (buttons, cards, badges, icons) that are stateless. clear_components() helper exposed for tests. (python/djust/components/function_component.py, python/djust/__init__.py)

  • Declarative component assigns and slots (Phoenix.Component parity)Assign("variant", type=str, default="default", values=["primary", "danger"], required=True) and Slot("col", multiple=True) DSL, declared on a LiveComponent class attribute (assigns = [...] / slots = [...]) or on function components via @component(assigns=[...], slots=[...]). Validation runs at mount/invoke: required-missing raises AssignValidationError in DEBUG and warns in production, type coercion (str → int / bool / float) is automatic, enum violations via values= raise. Child-class assigns extend (and override by name) parent declarations via MRO walk. (python/djust/components/assigns.py, python/djust/components/base.py)

  • Named slots with attributes via {% slot %} / {% render_slot %} tags — Parent templates pass named content blocks with attributes into components: {% call "card" %}{% slot header label="Title" %}Header{% endslot %}Body{% endcall %}. Multiple same-name slots collect into a list (essential for table columns, tab panels). Slots are exposed to the component as assigns["slots"] = {name: [{"attrs": {...}, "content": "..."}, ...]}. Non-slot content in the {% call %} body becomes children / inner_block. Implemented in pure Python via a sentinel-and-extract protocol — zero Rust parser/renderer changes. (python/djust/components/function_component.py)

Fixed

  • Attribute-context HTML escaping parity with Django (v0.5.0 P1 — Rust template engine parity) — Variables inside HTML attribute values now route through a dedicated html_escape_attr() that's guaranteed to escape "&quot; and '&#x27; (in addition to &/</>). Detection reuses the existing is_inside_html_tag_at() parser helper — the per-Node::Variable in_attr flag is computed at parse time, so renderer cost is a bool check. |safe still bypasses escaping in both attribute and text contexts. Today's behaviour is unchanged (the base html_escape already covered quotes) — this refactor makes the parse-time classification visible to the renderer so future changes to the default escape can't accidentally break attribute values like <a href="{{ url }}"> when url contains quotes. (crates/djust_templates/src/parser.rs, crates/djust_templates/src/filters.rs, crates/djust_templates/src/renderer.rs)
  • Inline conditional {{ x if cond else y }} now contributes deps to enclosing wrappers (#783, sibling bug) — Same failure mode as nested {% include %}: extract_from_nodes had no arm for Node::InlineIf, so its true_expr / condition / false_expr variables were silently dropped from the dep set of any surrounding {% if %} / {% for %} / {% with %}. Changing the condition alone (e.g. step_active in {% for s in steps %}<span class="{{ 'active' if step_active else 'idle' }}">) produced patches=[] and stale HTML. Fix: extract_from_nodes now extracts non-literal variables from all three InlineIf expressions.
  • Nested {% include %} now propagates wildcard dep to enclosing wrappers (#783) — Rust partial renderer reused the cached fragment of an {% if %} / {% for %} / {% with %} wrapping a nested {% include %}, because extract_from_nodes treated Include as having no variable references. When the included template referenced a context key that changed (e.g. {{ field_html.first_name|safe }}), the wrapper's dep set ({current_step_name}) did not intersect changed_keys ({field_html}), needs_render returned false, the cached HTML was reused, and the text-region fast-path compared byte-identical old/new HTML → patches=[] with diff_ms: 0. Manifested with deeply-nested WizardMixin templates ({% extends %} → {% block %} → {% if current_step_name == "..." %} → {% include "step_*.html" %}). Fix: extract_from_nodes now injects "*" into the variables map when it encounters a nested Include or CustomTag/BlockCustomTag during its walk, so wrapper deps include the wildcard and those nodes are always re-rendered. (crates/djust_templates/src/parser.rs)
  • _force_full_html now calls set_changed_keys so Rust partial renderer re-renders (#783) — When _force_full_html was set, _sync_state_to_rust() cleared prev_refs to force all context to Rust, but the set_changed_keys call was gated by if prev_refs which evaluated to False after clearing. Rust's partial renderer saw no changed_keys, fell back to full render with empty changed_indices, and the text-region fast-path compared identical old/new HTML → zero patches. Fix: set_changed_keys is now called when _force_full_html is set regardless of prev_refs.

Docs

  • ROADMAP correction: temporary_assigns is already implemented — The v0.5.0 ROADMAP entry claiming temporary_assigns was "completely absent from djust today" was inaccurate. The feature has shipped in earlier releases (LiveView._initialize_temporary_assigns / _reset_temporary_assigns, wired into the render cycle and excluded from change tracking). This PR adds a dedicated regression test (tests/unit/test_temporary_assigns.py) — prior coverage was indirect — and strikes through the ROADMAP entry.

Tests

  • Regression coverage for temporary_assignstests/unit/test_temporary_assigns.py covers reset-after-render semantics, default-value cloning per type (list / dict / set / scalar), idempotent initialization, pre-existing-attribute preservation, instance-level override, and the empty-mapping no-op path.
  • Unit tests for assign_async / AsyncResulttests/unit/test_assign_async.py (18 tests) covers state-flag invariants, frozen dataclass immutability, pending-is-set-immediately, success & failure propagation, multi-concurrent scheduling, cancellation interop with cancel_async, sync and async loaders, args/kwargs forwarding, and the generation-counter / stale-loader regression cases added in #793.
  • Unit tests for {% dj_suspense %}tests/unit/test_suspense.py (12 tests) covers ok → body, loading → fallback, failed → error-div, HTML-escaped error messages, no-await= passthrough, unknown / non-AsyncResult refs defaulting to loading, default spinner, Django template fallback, template-error graceful degradation, nesting, and whitespace-tolerant comma-separated lists.
  • Regression suite for |safe HTML blob diff (#783)tests/test_rust_vdom_safe_diff_783.py exercises the WizardMixin-style pattern where field_html is derived in get_context_data() from an instance attribute. Covers dict reassignment, in-place nested mutation, the _force_full_html codepath, an {% if %} branch swap, a {% extends %}/{% block %} inheritance chain, and the exact NYC-Claims-style {% extends %} + {% if %} + {% include %} structure that originally exhibited the bug. All variants assert non-empty VDOM patches on state change.
  • Dep-extractor hardening (#783 follow-up, P0) — Three-part hardening against silent dep-drop regressions in crates/djust_templates/src/parser.rs::extract_from_nodes:
    • Rust unit tests for extract_per_node_deps — table-driven assertions on representative AST shapes (simple Variable, If-wrapping-Include, For with tuple unpacking, With + body, InlineIf condition, nested For, Block recursion, plain Text). Explicit "*" wildcard membership checks for nested Include / CustomTag shapes.
    • Node variant exhaustiveness checksample_for_coverage exhaustive match on Node::* + sample_nodes() constructor + NO_VARS_VARIANTS allow-list. Any new Node variant fails to compile until the match is updated, and at runtime every non-allow-listed variant must produce a non-empty dep set (real vars or "*" wildcard). Makes silent dep-drops on future additions impossible.
    • Partial-render correctness harness (Python)TestPartialRenderCorrectness in tests/test_rust_vdom_safe_diff_783.py. Byte-equality oracle: for each of 6 wrapper shapes (no-wrapper, {% if %}, {% for %}, {% with %}, full #783 extends/if/include/safe chain, InlineIf-in-for), renders a mutation via the normal partial-render path then re-renders the same mutation with the Rust fragment cache cleared (clear_fragment_cache()) as a control. Any dep-miss that causes partial render to reuse a stale cached fragment diverges from the control and fails.
  • New PyO3 method DjustLiveView.clear_fragment_cache (test-only) (crates/djust_live/src/lib.rs) — clears node_html_cache, last_html, fragment_text_map, text_node_index while preserving last_vdom so the diff baseline is unchanged. Exclusively supports the partial-render correctness harness above; not intended for application use.

[0.5.0rc2] - 2026-04-20

Added

  • Bootstrap 4 CSS framework adapter — New Bootstrap4Adapter for projects using Bootstrap 4 (NYC Core Framework, government sites, legacy projects). Set DJUST_CONFIG = {"css_framework": "bootstrap4"}. Includes proper custom-select, custom-control-* classes for checkboxes/radios, and form-group wrappers.
  • Dedicated radio button classes — Radio buttons now use radio_class, radio_label_class, and radio_wrapper_class config keys (with fallback to checkbox classes). Both Bootstrap 4 and 5 configs define radio-specific classes.
  • Select widget class supportChoiceField with Select widget uses select_class config key (e.g., custom-select for BS4, form-select for BS5) instead of the generic field_class.
  • Theme-to-framework CSS bridge — New {% theme_framework_overrides %} template tag generates <style> overrides that map djust theme variables (--primary, --border, etc.) onto the active CSS framework's selectors (.btn-primary, .form-control, .alert-*, etc.). Switching themes now automatically re-styles Bootstrap 4/5 components.

Fixed

  • Derived container context values now tracked by value equality (#774) — The Rust state sync used id() comparison for all non-immutable context values, which is unreliable for containers (dict, list, tuple) due to CPython address reuse after GC. Derived values like current_step = wizard_steps[step_index] could be missed when the handler only changed step_index, causing Rust to render stale HTML. Fix: containers are now compared by value equality (like immutables already were), with previous values cached in _prev_context_containers. The optimization is preserved — unchanged containers are still skipped.

[0.5.0rc1] - 2026-04-19

Added

  • Package consolidation: all 5 runtime packages folded into djust — One install, one version, one CHANGELOG. pip install djust stays lean; pip install djust[all] gets everything.
    • Phase 1+2: djust-auth + djust-tenants → core (#770) — djust-auth (879 LOC) merged into python/djust/auth/ package with lazy imports. djust-tenants missing modules (audit, middleware, managers, models, security) merged into existing python/djust/tenants/. Both are core — no extras needed. 27 new tests.
    • Phase 3: djust-admindjust[admin] (#771) — 3,878 LOC merged into python/djust/admin_ext/ (avoids collision with django.contrib.admin). Views, forms, adapters, plugins, decorators, template tags, 7 HTML templates. 40 new tests.
    • Phase 4: djust-themingdjust[theming] (#772) — 49,105 LOC merged into python/djust/theming/. CSS theming engine, design tokens, 96 HTML templates, 9 static files, management command (djust_theme), 4 template tag modules, gallery sub-package. 749+ tests.
    • Phase 5: djust-componentsdjust[components] (#773) — ~100K LOC merged into python/djust/components/. 170+ UI component classes, 6 template tag modules, management command (component_gallery), descriptors, mixins, rust_handlers.py. Extra deps: markdown>=3.0, nh3>=0.2.

[0.4.5rc2] - 2026-04-18

Added

  • AI observability module: djust.observability — DEBUG-gated, localhost-only HTTP endpoints that give external tooling (like the djust Python MCP and djust-browser-mcp) live visibility into framework state without in-process coupling. Ships as seven endpoints under /_djust/observability/: health, view_assigns, last_traceback, log_tail, handler_timings, sql_queries, reset_view_state, eval_handler. Each pairs with a matching MCP tool. Security model mirrors django-debug-toolbar (DEBUG=True + LocalhostOnlyObservabilityMiddleware). Requires path("_djust/observability/", include("djust.observability.urls")) in the project urls.py.
  • get_view_assigns — Real server-side self.* state of the mounted LiveView for a given session. Complements browser-mcp's client-only djust_state_diff with the source of truth. Per-attr fallback tags non-serializable values with {_repr, _type} rather than an all-or-nothing blanket.
  • get_last_traceback — Ring-buffered (50) exception log populated from handle_exception(). Replaces "can you paste the terminal?" for 80% of blind-debugging cases.
  • tail_server_log — Ring-buffered (500) Django/djust log records with since_ms + level filters. djust.* captured at DEBUG+, django.* at WARNING+.
  • get_handler_timings — Per-handler rolling 100-sample distribution (min/max/avg/p50/p90/p99). Reuses existing timing["handler"] measurements; no extra perf counters.
  • get_sql_queries_since — Per-event SQL capture via connection.execute_wrappers. Queries are tagged with (session_id, event_id, handler_name) + stack_top filtered to skip framework frames.
  • reset_view_state — Replay view.mount() on a registered instance. Clears public attrs, re-invokes mount(stashed_request, **stashed_kwargs). Useful between fixture replays.
  • eval_handler — Dry-run a handler against a live view's current state. Returns {before_assigns, after_assigns, delta, result}. v2 dry_run=True installs a DryRunContext that blocks Model.save/delete, QuerySet.update/delete/bulk_create/bulk_update, send_mail/send_mass_mail, requests.*, and urllib.request.urlopen — first attempt raises DryRunViolation and the response surfaces {blocked_side_effect}. dry_run_block=False records without blocking. Process-wide lock serializes dry-runs.
  • find_handlers_for_template(template_path) in djust MCP — Cross-references a template file against every view that uses it, returning dj-* handlers wired in the template and the diff against view handler methods. Catches dead bindings at author time (complements djust-browser-mcp's runtime find_dead_bindings).
  • seed_fixtures(fixture_paths) in djust MCP — Subprocess wrapper around manage.py loaddata for regression-fixture DB setup.

Fixed

  • hotreload: suppress empty-patch broadcasts on unrelated file changes (#763) — When a Python file changes that doesn't affect the currently-mounted view, re-render produces zero patches. The old code still broadcast ~14 KB (empty patches + full _debug state dump) to every connected session. Early-return when hotreload=True AND patches==[]. Non-hot-reload empty patches still sent (loading-state clear ack needed).
  • client.js: guard 38 unguarded console.log calls (#761) — Per djust/CLAUDE.md rule, no console.log without if (globalThis.djustDebug) guard. Introduced a djLog helper in 00-namespace.js and replaced bare console.logdjLog across 12 client modules. console.warn/console.error untouched (real problems stay visible in prod).
  • Observability DryRunContext._uninstall logs setattr failures (#759) — Silent except Exception: pass meant the process could run indefinitely with a wrapped Model.save if uninstall partially failed — catastrophic for a dev server. Replaced with a logger.warning so the failure is observable.

Changed

  • djust.observability + eval_handler v2 — Side-effect blocking now covers QuerySet bulk writes (#758): QuerySet.update/delete/bulk_create/bulk_update are patched alongside Model.save/delete, so a handler that does Model.objects.filter(...).update(...) correctly raises DryRunViolation instead of silently committing.
  • Observability dry_run tests tightened (#760) — Two tests claimed to verify the record-but-allow contract but only checked detection. Now use unittest.mock to assert the original callable was actually invoked (call_count == 1) alongside the violation-recorded assertion.

[0.4.5rc1] - 2026-04-17

Changed

  • Text-region fast path now fires for {% extends %} templates — The scanner that builds the VDOM text-node position index used to process the full pre-hydration HTML, but the VDOM is rooted at [dj-root]. On templates extending a base (with <title>, meta tags, scripts outside dj-root), the scanner counted text runs in <head> and trailing <footer>/<script> siblings that the VDOM didn't have — the count mismatched, the index was discarded, and every event fell through to a full html5ever parse (~10ms on the djust.org /examples/ page). Now the scanner is restricted to the dj-root element's interior via a balanced-tag walker. Rust render drops from ~14ms → ~2.8ms on extends templates; browser E2E (production, DEBUG=False) drops from 30ms → ~25ms avg, 18ms min.

  • Text-region VDOM fast path — Extends the existing text-fast-path to handle changes that differ only in a text span, even when the surrounding fragment contains tags. Computes byte-level common prefix/suffix on pre-hydration HTML; if the divergence is a single tag-free text run, locates the owning VDOM text node via a pre-built positional index (binary search on (html_start, html_end, path, text, djust_id) entries, built once per full-parse render and kept in sync through fast-path events by shifting downstream entries by the byte delta). Patches in place and skips html5ever entirely. For a counter click inside a {% for %} loop on a 309KB page, Rust render drops from ~12ms to ~2.7ms. UTF-8 safe (snaps to char boundaries), handles <pre>/<code>/<textarea> whitespace preservation and <script>/<style> raw-text element bodies correctly, bails to full parse on entity-offset mismatches.

  • parse_html_fragment(html, context_tag) — New public entry point in djust_vdom that uses html5ever's parse_fragment with a parent-element context. Enables parsing isolated HTML fragments with correct tokenization for context-sensitive elements (<tr>, <td>, <option>), without resetting the dj-id counter. Scaffolding for future structural-fragment fast paths.

  • collect_vdom_text_nodes now skips comment nodes — Previously collected <!--dj-if--> placeholders into the text-node list, shifting every subsequent ordinal by one and breaking any position-based patching. Text and comment VNodes both carry text, so an explicit is_text() filter was needed.

  • Partial template rendering (#737) — Per-node dependency tracking at template parse time. On re-render, only template nodes whose context variable dependencies changed are re-rendered; unchanged nodes reuse cached HTML. For a single-variable change on a page with 50 template nodes, template render drops from ~1.4ms to ~0.1ms. Changed keys are passed from Python to Rust via set_changed_keys(), which merges across multiple sync calls. {% include %} and custom tags always re-render (wildcard dependency).

  • {% extends %} inheritance resolution caching — Templates using {% extends %} now participate in partial rendering. Inheritance is resolved once via OnceLock<ResolvedInheritance> on the Template struct (shared via TEMPLATE_CACHE). Final merged nodes and their deps are cached, so subsequent renders skip both chain building and static parent nodes. Combined with partial rendering, extends templates go from full re-render (~14ms Rust) to partial render of changed nodes only (~0.02ms Rust).

  • Text-only VDOM fast path — When all changed template fragments are plain text (no HTML tags), skip both html5ever parsing and VDOM diffing entirely. The old VDOM is mutated in-place via a fragment→text-node map built on first render, and SetText patches are produced directly. For counter-style updates: parse phase drops from ~12ms to ~0.001ms.

  • Block flattening for partial rendering{% block %} nodes left by Django's template engine are flattened to expose each child as a separate fragment. This enables the text fast path to activate on pages using {% extends %} where Django resolves blocks.

  • Faster change detection_snapshot_assigns uses identity + shallow fingerprints (id, length, content hash for list-of-dicts) instead of copy.deepcopy. Framework-internal keys (csrf_token, kwargs, temporary_assigns, DATE_FORMAT, TIME_FORMAT) and auto-generated _count keys are excluded from set_changed_keys to avoid spurious re-renders.

  • Optimized VNode parser — Pre-sized attribute HashMap, eliminated redundant to_lowercase() call, removed form element debug output.

Fixed

  • Derived immutable context values no longer go stale on partial re-render_sync_state_to_rust previously skipped id()-based change detection for immutable types (int/str/bool/bytes) to avoid false positives from Python's int cache, which meant derived values computed in get_context_data (e.g. completed_count = sum(...), total_count = len(...)) were never synced to Rust when their sources changed. Partial rendering would then reuse the cached HTML for template nodes depending on those values, leaving counters stale after add/toggle/delete. Fixed by tracking previous VALUES for immutable keys and comparing by equality. Regression tests in test_changed_tracking.py::TestDerivedImmutableSync.

  • VDOM input value leak on name change — When the patcher morphs an input into a different field (e.g., wizard step 1 name → step 2 email), the old field's typed value no longer leaks into the new field. Both morphElement and SetAttr patches now clear .value when the name attribute changes.

  • In-place dict mutation detection_snapshot_assigns now fingerprints list contents (id + dict values hash) to detect mutations like todo['completed'] = True that don't change the list's id or length. Falls back to id-only for unhashable values.

  • Derived context value detection — When _changed_keys is set, the sync also checks non-immutable context values by id() to catch derived values (e.g., products from _products_cache) that change via private attributes.

[0.4.4] - 2026-04-15

Changed

  • Remove double updateHooks()/bindModelElements() scanning — These were called in both applyPatches() and reinitAfterDOMUpdate(), scanning the full DOM twice per patch cycle. Removed from applyPatches(). Saves ~5ms per event.

  • Delegated scoped listeners (dj-window-, dj-document-) — Replaced querySelectorAll('*') full DOM scan with a registry-based delegation pattern. Scoped elements are scanned once at mount time and registered in a Map. Event listeners on window/document dispatch to the registry. Handles dotted attribute variants (dj-window-keydown.escape).

  • Use orjson.loads() for patch JSON parsing — 2-3x faster than stdlib json.loads() when orjson is installed. Falls back gracefully.

  • Gate debug payload behind panel open stateget_debug_update() (dir + getattr + json.dumps per attribute) only runs when the debug panel is actually open, not on every event in DEBUG mode. Saves ~2-5ms per event. Panel sends debug_panel_open/debug_panel_close WS messages on toggle.

[0.4.4rc1] - 2026-04-15

Fixed

  • VDOM patch path traversal skips regular HTML comments (#729) — The JS patcher was counting all HTML comment nodes during path traversal, but the Rust VDOM parser only preserves <!--dj-if--> placeholders. This caused every page with HTML comments in dj-root to fail VDOM patching and fall back to full HTML recovery.

  • Scroll to top on dj-navigate live_redirecthandleLiveRedirect() now scrolls to the top of the page (or to anchor if URL has a hash) after pushState.

Changed

  • Event delegation replaces per-element binding (#730)bindLiveViewEvents() no longer scans the DOM after every VDOM patch. Instead, one listener per event type is installed on the dj-root element via delegation (e.target.closest('[dj-click]')). This reduces client-side post-patch handling from ~56ms to ~30ms on large pages. Per-element rate limiting preserved via WeakMap.

Added

  • Per-phase Rust timing in render_with_diff() (#730) — Instrumentation measuring template render, html5ever parse, VDOM diff, and HTML serialization. Exposed to Python via get_render_timing() and propagated to WebSocket response performance metadata.

[0.4.3] - 2026-04-14

Fixed

  • {% csrf_token %} no longer renders poisoned CSRF_TOKEN_NOT_PROVIDED placeholder (#696) — The Rust template engine now renders an empty string when no CSRF token is in context (instead of a placeholder that poisoned client.js's CSRF lookup). Python LiveView _sync_state_to_rust() now injects the real token from get_token(request). Three-layer defense-in-depth fix merged as PR #708.

  • HTTP fallback POST no longer replaces page with logged-out render (#705) — The POST handler now applies _apply_context_processors() before render_with_diff() so auth context (user, perms, messages) is available during re-render. Context processor cleanup uses _processor_context() context manager for guaranteed cleanup. Merged as PR #710 + #714 + #721.

  • Rust |date and |time filters honor Django DATE_FORMAT/TIME_FORMAT settings (#713) — New apply_filter_with_context() checks the template context for format settings when no explicit format argument is given. Python injects Django settings into the Rust context during _sync_state_to_rust(). Merged as PR #714.

  • Rust |date filter now works on DateField values (#719) — The |date filter previously only parsed RFC 3339 datetime strings. DateField values (bare dates like "2026-03-15") are now parsed via a NaiveDate fallback pinned to midnight UTC. Merged as PR #720.

  • CSRF token value HTML-escaped in Rust renderer (#722) — The CSRF hidden input now uses the shared filters::html_escape() utility (escaping &, ", <, >, and single quotes) instead of a manual .replace() chain that missed single quotes. Defense-in-depth. Merged as PR #727.

  • Bare except: pass in CSRF injection now logs a warning (#716) — The CSRF token injection in _sync_state_to_rust() previously swallowed all exceptions silently. Now logs via djust.rust_bridge logger with exc_info=True. Merged as PR #721.

Changed

  • Context processor cleanup refactored to _processor_context() context manager (#717) — Replaced the manual try/finally in the HTTP fallback POST handler with a reusable @contextmanager that guarantees cleanup of temporarily injected view attributes. Merged as PR #721 + #727.

  • Pre-existing test fixestest_debug_state_sizes corrected for json.dumps(default=str) behavior and \uXXXX escaping. navigation.test.js suppresses happy-dom/undici WebSocket mock dispatchEvent incompatibility.

Added

  • Python integration tests for DATE_FORMAT settings injection (#718) — 4 tests verifying _sync_state_to_rust injects DATE_FORMAT/TIME_FORMAT from Django settings. Merged as PR #721.

  • Negative tests for |date filter invalid input (#725) — 4 Rust tests covering invalid dates, non-date strings, empty strings, and partial dates (filter returns original value per Django convention). Merged as PR #727.

  • format_date() doc comment documenting Django compatibility (#726) — Documents supported input formats (RFC 3339, YYYY-MM-DD) and unsupported types (epoch ints, locale strings). Merged as PR #727.

[0.4.2] - 2026-04-13

Fixed

  • Derived context vars synced when parent instance attr mutated in-place (#703)_sync_state_to_rust() now collects id()s of all sub-objects reachable from changed instance attrs and includes any derived context var whose id() appears in that set. Previously, context vars computed in get_context_data() that returned sub-objects of a mutated dict (e.g., wizard_step_data.get("person", {})) were skipped because their id() was unchanged, causing templates to render stale data. Depth-capped at 8 with cycle detection. 9 new regression tests.

  • as_live_field() now respects widget.input_type override for type attribute (#683 re-open) — The initial #683 fix merged widget.attrs but type was still ignored because Django moves type= from attrs into widget.input_type during widget __init__. _get_field_type() now checks widget.input_type against the widget class's default and uses the override when they differ (e.g. TextInput(attrs={"type": "tel"}) sets input_type="tel"). 4 new regression tests covering type="tel", type="url", type="search", and the default type="text" fallback.

Added

  • LiveComponent events now propagate to parent LiveView waiters (ADR-002 Phase 1b/1c follow-up) — Closes the "known limitation" documented in the v0.4.2 tutorials guide: await self.wait_for_event("foo") on a LiveView now resolves when the matching handler fires on an embedded LiveComponent, not just when it fires on the view itself. Without this, a TutorialStep(wait_for="submit", ...) where submit is a handler on a child FormComponent would silently stall forever — the parent view's waiter would never resolve and the tour would hang. The fix is in the WebSocket consumer's handle_event component-event branch: after the component handler runs, the consumer now calls self.view_instance._notify_waiters(event_name, notify_kwargs) with the handler's kwargs + an injected component_id key, mirroring the notification that already happened in the main LiveView branch from Phase 1b. The component_id injection means apps can use the waiter's predicate argument to disambiguate events fired from multiple component instances: wait_for_event("submit", predicate=lambda kw: kw.get("component_id") == "project_form"). A notification failure is caught and logged via the djust.websocket logger so a buggy waiter/predicate can't break the component handler's observable behavior — the component's state mutations always happen even if the waiter notification raises. 5 new regression tests in python/tests/test_waiter_component_propagation.py covering: component event resolves parent waiter, component_id is injected into notify kwargs so predicates can filter by source, multiple parent waiters for the same event all resolve (fan-out), the non-component branch still notifies parent waiters (regression guard for the Phase 1b path), and a raising _notify_waiters is logged-and-swallowed rather than propagating. docs/website/guides/tutorials.md Limitations section updated to document the new behavior with a component_id predicate example.

Documentation

  • Tutorial bubble must be placed outside dj-root (#699) — If the {% tutorial_bubble %} tag is placed inside the dj-root container, morphdom recovery (which replaces the entire dj-root content on patch failure) destroys the bubble mid-tour, causing it to silently disappear. The tutorials guide now has a dedicated "Bubble Placement" section explaining the requirement, why it exists, and correct/incorrect examples. The simplest-possible example at the top of the guide is updated to show the bubble outside dj-root. The tutorial_bubble template tag docstring is also updated with this requirement.

  • data-* attribute naming convention documented in Events guide (#623) — How data-foo-bar on an HTML element maps to foo_bar in the event handler's kwargs was undocumented. The Events guide now has a dedicated "Data Attribute Naming Convention" section covering: the dash-to-underscore rule, client-side type-hint suffixes (:int, :float, :bool, :json, :list), server-side Python type-hint coercion, the dj-value-* alternative, which internal data-* attributes are excluded, and a quick-reference table.

Changed

  • System checks T002, V008, C003 now suppressible via DJUST_CONFIG (#603) — These three informational checks fire on every manage.py invocation and are noisy for projects that deliberately don't use the checked features (daphne, explicit dj-root, non-primitive mount state). A new suppress_checks config key in DJUST_CONFIG (or LIVEVIEW_CONFIG) accepts a list of check IDs to silence: DJUST_CONFIG = {"suppress_checks": ["T002", "V008", "C003"]}. Both short IDs ("T002") and fully-qualified IDs ("djust.T002") are accepted, case-insensitive. Only the informational/advisory variants are suppressed — the C003 Warning (daphne misordered) still fires because it indicates a real misconfiguration. 7 new tests for the suppression mechanism.

  • release-drafter/release-drafter v6 → v7 + drop pull_request trigger — v7 validates target_commitish against the GitHub releases API and rejects refs/pull/<n>/merge refs, which is what github.ref resolves to under a pull_request trigger. v6 silently tolerated this; v7 does not, causing every PR to fail with Validation Failed: target_commitish invalid. The fix is to drop the pull_request trigger — release-drafter is designed to track changes that have landed on the release branch, not comment on in-flight PRs, so push: branches: [main] is the right fit. Aligns with how Phoenix, Elixir, GitHub CLI, and other major projects wire release-drafter. Resolves the v7 bump that was deferred out of the v0.4.2 dependabot batch (#680).

  • Dependency batch carry-over (v0.4.2) — Drains the dependabot backlog that was held behind the v0.4.1 release. Single consolidated PR so one CI run catches any inter-dep interactions:

    • npm: vitest / @vitest/ui / @vitest/coverage-v8 4.0.18 → 4.1.4 (patches + new test runner features), jsdom 29.0.1 → 29.0.2, happy-dom 20.8.4 → 20.8.9. Full JS suite remains green (1111 tests).
    • Cargo: tokio 1.50 → 1.51 (workspace), uuid 1.22 → 1.23, proptest 1.10 → 1.11 (djust_vdom), indexmap 2.13.0 → 2.14.0 (transitive pickup via cargo update). cargo check --workspace clean; cargo test -p djust_vdom passes all 42 proptest-driven tests on the new 1.11 runtime.
    • GitHub Actions: actions/github-script v8 → v9 (two workflows), astral-sh/setup-uv v6 → v7 (test workflow). Workflow syntax unchanged.
    • Intentionally deferred: html5ever 0.36 → 0.39 is a 3-minor-version jump that requires a matching markup5ever_rcdom 0.39 release which has not yet been published to crates.io (only git snapshots exist in the html5ever workspace). Using git deps in our published workspace would break cargo publish and leak unreleased upstream state, so this stays deferred until upstream publishes. release-drafter/release-drafter v6 → v7 was also deferred out of this chore batch because of a target_commitish validation incompatibility — shipped as a separate follow-up PR alongside this one.

    Closes 13 open dependabot PRs as superseded (#581, #582, #604, #606, #607, #609, #615, #616, #644, #645, #646, #647, #648).

Fixed

  • @background natively supports async def handlers (#697) — The @background decorator now detects asyncio.iscoroutinefunction and creates a native async closure so _run_async_work can await it directly on the event loop instead of routing through sync_to_async. The fragile inspect.iscoroutine(result) workaround from #692 is kept as a legacy fallback. 5 new regression tests.

  • flush_push_events() resolves callback dynamically on WS reconnect (#698)PushEventMixin.flush_push_events() now resolves the flush callback via self._ws_consumer._flush_push_events at call time instead of relying on a stored _push_events_flush_callback that was only wired during initial mount. After a WebSocket reconnect the view instance is restored from session but the stored callback was stale. The dynamic lookup always finds the current consumer. Legacy stored callback kept as fallback. 7 new tests.

  • push_commands-only handlers auto-skip VDOM re-render (#700) — Handlers that only call push_commands() / push_event() without changing public state no longer trigger a VDOM re-render. The _snapshot_assigns deep-copy comparison could report false positives for views with non-copyable public attributes (querysets, file handles) because sentinel objects never compare equal. A new identity-based check (id() comparison before/after) detects whether any public attribute was actually rebound and auto-sets _skip_render = True when push events are pending but no state changed. 5 new tests.

  • System check V010 detects wrong TutorialMixin MRO ordering at startup (#691) — Django's View.__init__ does not call super().__init__(), so writing class MyView(LiveView, TutorialMixin) silently skips TutorialMixin's initialisation. A new djust.V010 system check scans all LiveView subclasses at startup and emits an Error with a clear fix hint when TutorialMixin appears after a View-derived base in the class declaration. Suppressible via DJUST_CONFIG = {"suppress_checks": ["V010"]}. 5 new tests. Tutorials guide updated with correct ordering.

  • @background async def handlers now execute correctly (#692)@background wraps handlers in a sync closure; when the handler is async def, the closure returned an unawaited coroutine and the handler body never ran. The fix in _run_async_work (already on main via workaround) detects coroutine returns and awaits them. 11 new regression tests in test_background_async.py verify both sync and async handlers execute their bodies.

  • push_commands in @background tasks now flush mid-execution (#693) — Push events queued by push_commands inside a @background handler only reached the client when the entire task completed. The _flush_pending_push_events callback mechanism (already on main) lets TutorialMixin and other background handlers flush events immediately. A new public await self.flush_push_events() method on PushEventMixin provides the same capability to any @background handler. 7 new tests in test_push_flush_background.py.

  • get_context_data no longer includes non-serializable class attributes (#694) — The MRO walker in ContextMixin.get_context_data() added class-level attributes (like tutorial_steps) to the template context. Non-JSON-serializable values were silently converted to their str() repr, corrupting state on subsequent events. The fix skips class-level attributes that fail a JSON serialisability probe. Additionally, TutorialMixin now stores steps as _tutorial_steps (private) with a read-only tutorial_steps property, so they are excluded by both the _ prefix convention and the serialisability check. 14 new tests.

  • Debug panel SVG attributes no longer double-escaped (#613) — SVG attributes like viewBox and path d in the debug toolbar were rendered garbled because the Rust VDOM's to_html() method HTML-escaped text content inside <script> and <style> elements. Per the HTML spec, these are "raw text elements" whose content must be emitted verbatim — escaping & to &amp; or < to &lt; corrupts JavaScript/CSS code and causes double-escaping when the HTML is round-tripped through the VDOM pipeline (parse with html5ever which decodes entities, then re-serialize with to_html() which re-encodes them). The fix adds an in_raw_text flag to the internal _to_html() serializer that propagates through <script>/<style> children, skipping html_escape() for their text nodes. SVG attribute values in templates (which don't contain HTML special characters) were already correct but now have explicit regression tests. 4 new Rust unit tests, 3 new Rust integration tests (script/style/SVG roundtrip), 3 new Python regression tests (JS source validation, JSON injection check, VDOM roundtrip), and 3 new JS tests (tab icon SVGs, path d attributes, header button SVGs all verified in DOM).

  • form.cleaned_data Python types no longer serialize to null (#628)datetime.date, datetime.datetime, datetime.time, Decimal, and UUID values in form.cleaned_data stored in public view state are now properly serialized to their JSON representations (ISO strings, floats, strings) instead of silently becoming null. Both the DjangoJSONEncoder and normalize_django_value() already handled these types; 10 new regression tests confirm the behavior.

  • set() is now JSON-serializable as public state (#626) — Storing a Python set() or frozenset() in public view state no longer crashes json.dumps. Sets are serialized as sorted lists (falling back to unsorted when elements aren't comparable). Both DjangoJSONEncoder.default() and normalize_django_value() now handle set/frozenset. 11 new regression tests.

  • dict state no longer corrupted to list after Rust state sync (#612) — Round-tripping state through the Rust MessagePack serialization boundary could corrupt dict values into list because #[serde(untagged)] on the Value enum let rmp_serde match a msgpack map against the List variant before trying Object. The fix replaces the derived Deserialize with a custom visitor-based implementation that uses the deserializer's type hints (visit_map vs visit_seq) to correctly distinguish maps from arrays. 4 new Rust regression tests + 1 Python end-to-end msgpack round-trip test.

  • as_live_field() now merges widget.attrs into rendered HTML (#683) — The as_live_field() method (and {% live_field %} tag) dropped any attributes defined on a Django widget's attrs dict — type="email", placeholder, pattern, min/max, custom data-*, and any other HTML attributes were silently lost. The fix adds _merge_widget_attrs() to BaseAdapter, called from _render_input, _render_checkbox, and _render_radio, which merges field.widget.attrs into the output attributes with djust-specific keys (dj-change, name, class, etc.) taking precedence over widget defaults. Boolean False/None values in widget attrs are filtered out to avoid rendering disabled="False". 17 new regression tests in python/tests/test_live_field_widget_attrs.py covering: EmailInput placeholder/type, pattern/min/max/step/title, djust attrs override clashing widget attrs, empty widget attrs, textarea rows/cols, checkbox data-attrs, radio data-attrs on each option, select data-attrs, and boolean True/False handling.

  • VDOM patcher guards against text nodes for 5 patch types (#622) — The VDOM diff patcher called setAttribute(), removeAttribute(), appendChild(), removeChild(), and replaceChild() on #text nodes, which don't implement these methods. This crashed conditional rendering whenever a text node sat where the patcher expected an element (common in {% if %} blocks that switch between text and element content). The fix adds an isElement(node) guard at the top of each of the five patch-type branches in 12-vdom-patch.js — when the target is a non-element node (text, comment, CDATA), the patch is skipped gracefully instead of throwing. 4 new JS tests in tests/js/vdom_patch_errors.test.js covering setAttribute, removeAttribute, appendChild, and replaceChild on text nodes.

  • Autofocus handling on dynamically inserted elements (#617) — Dynamically inserted <input autofocus> elements didn't receive focus after a VDOM patch because the browser only honours the autofocus attribute on initial page load. The patcher now detects autofocus on newly inserted elements after each patch cycle and calls .focus() explicitly. 4 new JS tests in tests/js/vdom-autofocus.test.js covering single autofocus, multiple elements (last wins), elements without autofocus ignored, and no-op when no autofocus elements are present.

  • Private _ attributes preserved across events and reconnects (#627, #611) — Two related state-management bugs caused any attribute starting with _ (the documented convention for private/internal state) to be silently wiped. The root cause was that session save used the output of get_context_data(), which by design strips _-prefixed attributes. For #627, every WebSocket event round-trip lost private state because _save_state_to_session() persisted only public context. For #611, the pre-rendered WS reconnect path restored session state but never included private attributes set during the HTTP GET mount. The fix adds two helpers — _get_private_state() (collects all _-prefixed instance attrs that aren't dunder or in the base-class exclusion set) and _restore_private_state(state_dict) — and wires them into _save_state_to_session() (now persists private state under a _private_state session key) and _load_state_from_session() / the reconnect path in RequestMixin._restore_session_state() (restores private attrs before the view resumes). 20 new regression tests in python/tests/test_private_attr_preservation.py covering: private attrs survive event dispatch, survive reconnect, survive multiple sequential events, coexist with public attrs, handle None/complex/nested values, are excluded for dunder attrs, are excluded for base-class internals, and round-trip through session save/load.

  • Layout flash on pre-rendered mount: defer reinitAfterDOMUpdate via requestAnimationFrame (#619, fixes #618) — Carry-over bugfix from v0.4.1. When a page is pre-rendered via HTTP GET, the WebSocket mount used to call reinitAfterDOMUpdate() synchronously right after stamping dj-id attributes onto the existing DOM. That synchronous call triggered a full DOM traversal for event binding, which forced the browser to recalculate layout mid-paint — and on pages with large pre-rendered elements (e.g. big dashboard stat values) the elements briefly rendered at the wrong size before settling, producing a visible layout-flash on every initial load. The fix moves the post-mount block (reinit + _mountReady flag + form recovery + auto-recover) into a runPostMount closure and schedules it via requestAnimationFrame(runPostMount) when available, falling back to a synchronous call when requestAnimationFrame is unavailable (JSDOM tests, exotic non-browser environments). Event binding now happens after the browser finishes its current paint, eliminating the flash entirely. The ordering invariant (reinit → _mountReady → form recovery) is preserved inside the closure so dj-mounted handlers and recovered form inputs still see bound event listeners. The non-prerendered data.html innerHTML-replace branch is unchanged — it already invalidates layout via the full DOM swap so there's no pre-paint to protect. 8 new regression tests in tests/js/mount-deferred-reinit.test.js asserting: the rAF wrapper is present, the synchronous fallback is preserved, the closure is named runPostMount for stable debugging, reinitAfterDOMUpdate() runs before _mountReady inside the closure, _mountReady is set inside the closure (not synchronously), form recovery runs only on reconnect inside the closure, the non-prerendered branch calls reinit synchronously, and exactly one call-site of reinitAfterDOMUpdate() exists in the skipMountHtml branch (so a refactor that reintroduces the sync call would immediately flip red). Closes #619 as superseded and closes the original #618 bug report.

  • Scaffolded projects now default DEBUG=False and generate .env.example (#637) — Carry-over bugfix from v0.4.1. Previously, python -m djust startproject mysite and python -m djust new mysite both generated a settings.py with DEBUG = True and ALLOWED_HOSTS = ["*"] as hardcoded literals. A developer who deployed the scaffolded output without remembering to flip those values ran production with full stack traces, the django-insecure-<random> default SECRET_KEY, and a wildcard host allowlist — the exact footgun that A001 (DEBUG enabled) and A014 (ALLOWED_HOSTS too permissive) flag in djust_audit. Now both scaffold paths (cli.py's cmd_startproject and the higher-level djust.scaffolding.generator.generate_project) emit DEBUG = os.environ.get("DEBUG", "False").lower() in ("true", "1", "yes") and ALLOWED_HOSTS = [host.strip() for host in os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") if host.strip()] — unconfigured deployments fail safe. The scaffold also writes a .env.example template alongside .gitignore (which already ignores .env) so local development picks up developer-friendly values via cp .env.example .env + whatever .env loader the developer uses. The .env.example includes DEBUG=True, a freshly-generated SECRET_KEY token (via secrets.token_urlsafe(50)), and ALLOWED_HOSTS=localhost,127.0.0.1 so the local experience hasn't changed. 4 new regression tests in python/tests/test_cli_scaffold.py asserting: DEBUG = True is no longer literal, DEBUG reads from env with "False" fallback, ALLOWED_HOSTS = ["*"] is no longer literal, narrow localhost,127.0.0.1 env default, .env.example exists with the three documented vars and a real (not template-placeholder) secret key, .env remains in .gitignore while .env.example does not. Closes #637.

Added

  • TutorialMixin + TutorialStep + {% tutorial_bubble %} — declarative guided tours (ADR-002 Phase 1c) — Capstone of ADR-002 Phase 1: a one-import, zero-JavaScript way for any djust app to ship a real guided tour, onboarding flow, or wizard. Apps declare the tour as a list of TutorialStep dataclasses on a LiveView that mixes in TutorialMixin; the framework runs the state machine as a @background task, pushing a highlight + narrate + focus chain at each step's target via push_commands (Phase 1a), then either asyncio.sleep'ing for auto-advance steps or awaiting wait_for_event (Phase 1b) until the user actually fires the matching @event_handler. Four event handlers come for free — start_tutorial, skip_tutorial, cancel_tutorial, restart_tutorial — along with three instance attributes (tutorial_running, tutorial_current_step, tutorial_total_steps) for progress display in the view state. TutorialStep supports per-step target (CSS selector, required), message (narration text), position (top/bottom/left/right bubble hint), wait_for (handler name to suspend on), timeout (seconds — pairs with wait_for for bounded waits or used alone for auto-advance), on_enter/on_exit (optional extra JSChain pushes for per-step setup/teardown beyond the default highlight + narrate + focus), and highlight_class/narrate_event (override per-step CSS class and CustomEvent name when you need different visual treatment). Skip and cancel signals are raced against the wait via asyncio.wait(..., return_when=FIRST_COMPLETED) so either unblocks the current step immediately; WebSocket disconnect cancels the background task automatically so there's no lingering work, no leaked waiters, no stuck highlights. A new {% tutorial_bubble %} template tag renders a floating narration bubble that listens for tour:narrate CustomEvents at document level (dispatched at the step's target with bubbles: true), positions itself next to the target per the step's position hint, displays step N / total progress, and includes "Skip" and "Close" buttons pre-bound to the mixin's event handlers — the default bubble is marked dj-update="ignore" so morphdom doesn't clobber it during VDOM patches. The new client-side src/28-tutorial-bubble.js module (~140 lines, brings client.js to 30 modules) registers its listeners unconditionally at IIFE time, reads detail.text/target/position/step/total from the event, and updates the bubble's text + progress + position + visibility. The framework ships no CSS — apps style the bubble and highlight class themselves (the guide includes a minimal starter block). 26 new Python tests for the mixin covering TutorialStep dataclass (minimal, custom position, invalid position, empty target, empty message, wait_for+timeout, on_enter/on_exit), lifecycle (initial state, empty-steps no-op, single step, setup+cleanup chain order, multi-step order, idempotent start-while-running), wait_for_event integration (step suspends on user action, timeout advances silently, indefinite wait), skip/cancel paths (advance past current, abort loop, no-op when not running), on_enter/on_exit pushes, per-step highlight class override, and per-step narrate event override. 9 new Python tests for the tutorial_bubble template tag covering defaults, custom css_class/event/position, invalid-position fallback to "bottom", skip+cancel button bindings, text/progress element classes, and XSS escaping of hostile css_class and event kwargs. 12 new JS tests in tests/js/tutorial-bubble.test.js covering listener registration, text content updates, progress text updates, show/hide via data-visible, default/custom position application, missing-target graceful handling, missing-bubble graceful handling, tour:hide event, and repeated updates on subsequent events. Zero new runtime dependencies — stdlib asyncio + dataclasses + Django's format_html. Full documentation in the new docs/website/guides/tutorials.md guide with the simplest-possible example, state-machine description, TutorialStep reference, wait_for/timeout combinations table, on_enter/on_exit patterns, the bubble template tag docs, a starter CSS block, four usage patterns (auto-advance walkthrough, interactive onboarding, mixed, branching with custom handlers), skip/cancel UX, disconnect cleanup, debugging tips, and honest limitations (LiveComponent events don't propagate to parent waiters yet, actor-mode views bypass the dispatch hook, handler validation failures prevent the waiter from resolving except via timeout, single-user only — multi-user broadcast is Phase 4 in v0.5.x).

  • await self.wait_for_event(name, timeout=None, predicate=None) async primitive (ADR-002 Phase 1b) — Second half of the backend-driven UI Phase 1 primitives. Adds a new WaiterMixin (automatically included in LiveView) that lets a @background handler suspend until a specific @event_handler is called by the user, optionally filtered by a predicate, optionally bounded by a timeout. The returned dict is the kwargs that were passed to the matching handler. This is the primitive that makes "highlight this button, wait for the user to actually click it, then advance to the next step" work declaratively — required by TutorialMixin (Phase 1c) and by any server-driven flow that needs to pause mid-plan until real user input arrives. Implementation: ~180 lines in python/djust/mixins/waiters.py, a ~15-line hook in python/djust/websocket.py that calls _notify_waiters after every successful handler invocation, a ~10-line cleanup hook in the WebSocket disconnect path that cancels all pending waiters when the view tears down (so @background tasks unblock with CancelledError instead of leaking), and proper integration into LiveView's MRO via python/djust/mixins/__init__.py. The notify pass runs AFTER the handler completes so waiters created during a handler call aren't self-notified (prevents re-entrancy surprises where wait_for_event("X") inside an X handler would resolve against itself). Multiple concurrent waiters for the same event name all resolve with the same kwargs dict when that event fires — fan-out patterns work without manual coordination. Waiters for different event names are fully independent. A predicate that raises is treated as "no match" and logged via the djust.waiters logger, so a buggy predicate can't crash the event pipeline or deadlock a background task. 18 new Python tests covering: basic resolution, kwargs copy semantics, no-op on unmatched names, predicate filtering, predicate-that-raises treated as False with warning log, predicate=None matches any kwargs, timeout raises asyncio.TimeoutError, expired waiters removed from registry, indefinite waits without timeout, concurrent waiters for same event all resolve, waiters for different events are independent, partial resolution (some predicates match, others don't), _cancel_all_waiters unblocks pending futures with CancelledError and clears the registry, task cancellation removes the waiter, and stability under mid-iteration waiter-list mutation. Full documentation in the existing docs/website/guides/server-driven-ui.md guide with signature, predicate examples, concurrency semantics, timeouts and cleanup, composition with push_commands, and honest limitations (no component-event support yet, actor mode bypasses the hook, validation failures prevent handler execution which means waiters never resolve except via timeout).

  • LiveView.push_commands(chain) + djust:exec client-side auto-executor (ADR-002 Phase 1a) — First half of the backend-driven UI primitives proposed in ADR-002. Adds a one-line server-side helper self.push_commands(chain) that takes a djust.js.JSChain (shipped in v0.4.1 as the JS Commands fluent API) and pushes it to the current session as a djust:exec push event carrying the chain's JSON-serialized ops list. The client half is a new framework-provided src/27-exec-listener.js module that listens for djust:push_event CustomEvents on window, filters for event === 'djust:exec', and runs the ops via window.djust.js._executeOps(ops, document.body) — the same function that runs inline dj-click="[[...]]" JSON chains and fluent-API .exec() calls from hook code. No hook registration, no template markup, no user setup required: the auto-executor ships bound with client.js and is active on every djust page automatically. The server-side helper is type-safe — it rejects anything that isn't a JSChain with a clear TypeError pointing at the JS.* factory methods, preventing raw ops-list smuggling through the push_event path. push_commands and push_event share the same queue and preserve ordering, so handlers can interleave "push a flash message, add a CSS class, fire analytics, run an animation" in one deterministic sequence. 23 new Python tests covering single-op chains, multi-op ordering, empty chains, JSON round-trip, immutability of chains after push, type validation against strings/dicts/lists/None, queue composition with push_event, and per-op factory parity across all 11 JS Commands. 13 new JS tests in tests/js/exec-listener.test.js covering listener registration, single-op execution, multi-op ordering, multiple-class add_class, focus, dispatch with detail, filtering for non-djust:exec events, malformed-payload rejection (missing ops, non-array ops, missing detail), error resilience (one bad op doesn't break the chain), multiple independent exec fires, and end-to-end integration with the fluent window.djust.js chain factory. Zero new runtime dependencies. Full documentation in docs/website/guides/server-driven-ui.md with patterns, debugging tips, and pointers to Phase 1b (wait_for_event) and Phase 1c (TutorialMixin) still to come in v0.4.2.

[0.4.1] - 2026-04-11

Added

  • JS Commands — client-side DOM commands chainable from templates, views, hooks, and JavaScript — Closes the single biggest DX gap vs Phoenix LiveView 1.0. Eleven commands (show, hide, toggle, add_class, remove_class, transition, dispatch, focus, set_attr, remove_attr, push) that run locally without a server round-trip, plus a push escape hatch that mixes in server events when needed. Four equivalent entry points: (1) Python helper djust.js.JS — fluent chain builder that stringifies to a JSON command list, wrapped in SafeString for safe template embedding (<button dj-click="{{ JS.show('#modal').add_class('active', to='#overlay') }}">Open</button>). (2) Client-side window.djust.js — mirror of the Python API with camelCase method names for direct JavaScript use (window.djust.js.show('#modal').addClass('active', {to: '#overlay'}).exec()). (3) Hook API — every dj-hook instance now has a this.js() method returning a chain bound to the hook element (Phoenix 1.0 parity for programmable JS Commands from hook lifecycle callbacks). (4) Attribute dispatcherdj-click (and other event-binding attributes) detect whether the attribute value is a JSON command list ([[...]]) and execute it locally; plain handler names still fire server events as before (zero breaking changes). All commands support scoped targets: to=<selector> (absolute document.querySelectorAll), inner=<selector> (scoped to origin element's descendants), closest=<selector> (walk up the DOM from origin) — a single <button dj-click="{{ JS.hide(closest='.modal') }}">Close</button> works in every modal with no per-instance IDs. The push command accepts page_loading=True to show the navigation-level loading bar while the event round-trips. Chains are immutable — every chain method returns a new JSChain, so reusing a base chain across multiple call sites never cross-contaminates. 37 new Python tests (every command + target validation + chain immutability + HTML/SafeString integration + template rendering) and 30 new JS tests (every command executing against real DOM + target resolution + chain fluency + attribute dispatcher + backwards-compat for plain event names + parseCommandValue edge cases). Zero new dependencies — the Python helper is stdlib-only and the JS interpreter is ~350 lines in a new src/26-js-commands.js module. Full guide in docs/website/guides/js-commands.md with examples for templates, hooks, chaining, and the "when to reach for what" decision tree.

  • dj-paste — paste event handling — New attribute that fires a server event when the user pastes content into a bound element (<textarea dj-paste="handle_paste">). The client extracts structured payload from the ClipboardEvent in one pass: text (clipboardData.getData('text/plain')), html (getData('text/html') for rich paste from Word/Google Docs/web pages), has_files (bool), and files (list of {name, type, size} metadata dicts for every file in clipboardData.files). When the element also carries a dj-upload="<slot>" attribute, the clipboard's FileList is routed through the existing upload pipeline — image-paste → chat, CSV-paste → table, etc. — via a new window.djust.uploads.queueClipboardFiles(element, fileList) export. Participates in the standard interaction pipeline (dj-confirm, dj-lock). By default the browser's native paste still happens so hybrid editors feel natural; add dj-paste-suppress to intercept fully (useful when routing image paste to an upload slot without dumping a data URL into a <div contenteditable>). Positional args in the attribute syntax (dj-paste="handle_paste('chat', 42)") forward via kwargs["_args"]. 11 new JS tests covering text extraction, HTML extraction, file metadata, suppress flag, missing clipboardData, double-bind protection, positional args, upload routing with and without a dj-upload slot, and graceful degradation when getData('text/html') throws. ~80 lines JS. Full guide in docs/website/guides/dj-paste.md.

  • djust_audit --ast — AST security anti-pattern scanner (#660) — Adds a new mode to djust_audit that walks the project's Python source and Django templates looking for five specific security anti-patterns, each motivated by a live vulnerability or near-miss in the 2026-04-10 NYC Claims penetration test. Seven stable finding codes djust.X001djust.X007: X001 (ERROR) — possible IDOR: Model.objects.get(pk=...) inside a DetailView / LiveView without a sibling .filter(owner=request.user) (or user=, tenant=, organization=, team=, created_by=, author=, workspace=) scoping the queryset. X002 (WARN) — state-mutating @event_handler without any permission check (no class-level login_required/permission_required, no @permission_required/@login_required). X003 (ERROR) — SQL string formatting: .raw() / .extra() / cursor.execute() passed an f-string, a .format() call, or a "..." % ... binary-op. X004 (ERROR) — open redirect: HttpResponseRedirect(request.GET[...]) / redirect(...) without an url_has_allowed_host_and_scheme or is_safe_url guard in the enclosing function. X005 (ERROR) — unsafe mark_safe / SafeString wrapping an interpolated string (XSS risk). X006 (WARN) — template uses {{ var|safe }} (regex scan of .html files). X007 (WARN) — template uses {% autoescape off %}. Suppression via # djust: noqa X001 on the offending line, or {# djust: noqa X006 #} inside templates. New CLI flags: --ast, --ast-path <dir>, --ast-exclude <prefix> [...], --ast-no-templates. Supports --json and --strict (fail on warnings too). 52 new tests covering positive + negative cases for every checker, management-command integration, template scanning, and noqa suppression. Zero new runtime dependencies — stdlib ast + re. Full documentation in docs/guides/djust-audit.md and docs/guides/error-codes.md#ast-anti-pattern-scanner-findings-x0xx. Closes the v0.4.1 audit-enhancement batch (#657/#659/#660/#661 all shipped).

  • New consolidated djust_audit command guidedocs/guides/djust-audit.md documents all five modes of the command (default introspection, --permissions, --dump-permissions, --live, --ast), every CLI flag, CI integration examples, and exit-code conventions. Cross-linked from docs/guides/security.md.

  • Error code reference expanded with 44 new codesdocs/guides/error-codes.md now covers the A0xx static audit checks (7 codes: A001, A010, A011, A012, A014, A020, A030), the P0xx permissions-document findings (7 codes: P001–P007), and the L0xx runtime-probe findings (30 codes: L001–L091). Every code gets severity, cause, fix, and a reference to the related issue/PR.

  • {% live_input %} template tag — standalone state-bound form fields for non-Form views (#650)FormMixin.as_live_field() and WizardMixin.as_live_field() render form fields with proper CSS classes, dj-input/dj-change bindings, and framework-aware styling — but only for views backed by a Django Form class. This leaves non-form views (modals, inline panels, search boxes, settings pages, anywhere state lives directly on view attributes) without an equivalent helper. The new {% live_input %} tag fills this gap with a lightweight alternative that needs no Form class or WizardMixin. Supports 12 field types (text, textarea, select, password, email, number, url, tel, search, hidden, checkbox, radio), explicit event= override (defaults sensibly per type — textdj-input, select/radio/checkboxdj-change, hidden → none), debounce=/throttle= passthrough, framework CSS class resolution via config.get_framework_class('field_class'), HTML attribute passthrough with underscore-to-dash normalisation (aria_label="Search"aria-label="Search"), and a tested XSS escape boundary via a new shared djust._html.build_tag() helper. Example: {% live_input "text" handler="search" value=query debounce="300" placeholder="Search..." %}. 56 new tests including an explicit XSS matrix across every field type and attribute. See docs/guides/live-input.md for the full setup guide.

  • djust_audit --live <url> — runtime security-header and CSWSH probe (#661) — Adds a new mode to djust_audit that fetches a running deployment with stdlib urllib and validates security headers (HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, COOP, CORP), cookies (HttpOnly, Secure, SameSite on session/CSRF cookies), information-disclosure paths (/.git/config, /.env, /__debug__/, /robots.txt, /.well-known/security.txt), and optionally probes the WebSocket endpoint with Origin: https://evil.example to verify the CSWSH defense from #653 is actually enforced end-to-end. This catches the class of production issues where the setting is correctly configured in settings.py but the response is stripped, rewritten, or never emitted by the time it reaches the client — the NYC Claims pentest caught a critical Content-Security-Policy missing case this way (django-csp was configured but the header was absent from production responses, stripped by an nginx ingress). 30 new stable finding codes djust.L001djust.L091 cover every check class so CI configs can suppress specific codes by number. New CLI flags: --live <url>, --paths (multi-URL), --no-websocket-probe, --header 'Name: Value' (for staging auth), --skip-path-probes (for WAF-protected environments). Supports --json and --strict (fail on warnings too). Zero new runtime dependencies — stdlib urllib for HTTP, optional websockets package for the WebSocket probe (skipped with an INFO finding if not installed).

  • New static security checks in djust_check / djust_audit (#659) — Seven new check IDs fire from check_configuration when Django runs python manage.py check: A001 (ERROR) — WebSocket router not wrapped in AllowedHostsOriginValidator (static-analysis companion to #653 for existing apps built from older scaffolds). A010 (ERROR) — ALLOWED_HOSTS = ["*"] in production. A011 (ERROR) — ALLOWED_HOSTS mixes "*" with explicit hosts (the wildcard makes the explicit entries meaningless). A012 (ERROR) — USE_X_FORWARDED_HOST=True combined with wildcard ALLOWED_HOSTS enables Host header injection. A014 (ERROR) — SECRET_KEY starts with django-insecure- in production (scaffold default not overridden before deployment). A020 (WARNING) — LOGIN_REDIRECT_URL is a single hardcoded path but the project has multiple auth groups/permissions (catches the "every role lands on the same dashboard" anti-pattern). A030 (WARNING) — django.contrib.admin installed without a known brute-force protection package (django-axes, django-defender, etc.). Each check has essentially zero false-positive risk, has a fix_hint pointing at the remediation, and was motivated by the 2026-04-10 NYC Claims pentest report. Out of scope for this PR: manifest scanning (k8s/helm/docker-compose env blocks) — deferred to a follow-up. Python-level settings.py values cover the common case.

  • djust_audit --permissions permissions.yaml — declarative permissions document for CI-level RBAC drift detection (#657) — Adds a new flag to djust_audit that validates every LiveView against a committed, human-readable YAML document describing the expected auth configuration for each view. CI fails on any deviation (view declared public but has auth in code, permission list mismatch, undeclared view in strict mode, stale declaration, etc.). This closes a structural gap the existing audit couldn't catch: djust_audit today can tell "no auth" from "some auth", but not that login_required=True should have been permission_required=['claims.view_supervisor']. The permissions document IS the ground truth. Seven stable error codes (djust.P001 through djust.P007) cover every deviation class. Also adds --dump-permissions to bootstrap a starter YAML from existing code, and --strict to fail CI on any finding. Full documentation in docs/guides/permissions-document.md. Motivated by NYC Claims pentest finding 10/11 where every view had login_required=True set and djust_audit reported them all as protected, but the lowest-privilege authenticated user could ID-walk the entire database.

  • WizardMixin for multi-step LiveView form wizards — General-purpose mixin managing step navigation, per-step validation, and data collection for guided form flows. Provides next_step, prev_step, go_to_step, update_step_field, validate_field, and submit_wizard event handlers. Template context includes step indicators, progress, form data/errors, and pre-rendered field HTML via as_live_field(). Re-validates all steps on submission to guard against tampered WebSocket replays. (#632)

Security

  • LOW: Nonce-based CSP support — drop 'unsafe-inline' from script-src / style-src — djust's inline <script> and <style> emissions (handler metadata bootstrap in TemplateMixin._inject_handler_metadata, live_session route map in routing.get_route_map_script, and the PWA template tags djust_sw_register, djust_offline_indicator, djust_offline_styles) now read request.csp_nonce when available (set by django-csp when CSP_INCLUDE_NONCE_IN covers the relevant directive) and emit a nonce="..." attribute on the tag. When no nonce is available (django-csp not installed, or CSP_INCLUDE_NONCE_IN not set), the tags emit without a nonce attribute — fully backward compatible with apps still allowing 'unsafe-inline'. Apps that want strict CSP can now set CSP_INCLUDE_NONCE_IN = ("script-src", "script-src-elem", "style-src", "style-src-elem") in settings.py, drop 'unsafe-inline' from CSP_SCRIPT_SRC / CSP_STYLE_SRC, and get strict CSP XSS protection across all djust-generated inline content. The PWA tags djust_sw_register, djust_offline_indicator, and djust_offline_styles now use takes_context=True to read the request from the template context — they still work with the same template syntax ({% djust_sw_register %} etc.) as long as a RequestContext is used (Django's default for template rendering). See docs/guides/security.md for the full setup. Reported via external penetration test 2026-04-10 (FINDING-W06). Closes the v0.4.1 security hardening batch (#653 / #654 / #655). (#655)

  • MEDIUM: Gate VDOM patch timing/performance metadata behind DEBUG / DJUST_EXPOSE_TIMINGLiveViewConsumer previously attached timing (handler/render/total ms) and performance (full nested timing tree with handler and phase names) to every VDOM patch response unconditionally, regardless of settings.DEBUG. Combined with CSWSH (#653) this let cross-origin attackers observe server-side code-path timings, enabling timing-based code-path differentiation (DB hit vs cache miss, valid vs invalid CSRF), internal handler/phase name disclosure, and load-based DoS scheduling. Now gated on a new helper _should_expose_timing() which returns True only when settings.DEBUG or the new settings.DJUST_EXPOSE_TIMING is True. Upgrade notes: production behavior change — existing clients that consumed response.timing / response.performance in production will no longer see those fields; opt in via DJUST_EXPOSE_TIMING = True in settings for staging/profiling. The browser debug panel is unaffected (it receives timing via the existing _attach_debug_payload path, which is already gated on DEBUG). Reported via external penetration test 2026-04-10. References: CWE-203, CWE-215, OWASP A09:2021. (#654)

  • HIGH: Validate WebSocket Origin header to prevent Cross-Site WebSocket Hijacking (CSWSH)LiveViewConsumer.connect() previously accepted the WebSocket handshake without validating the Origin header, and DjustMiddlewareStack did not wrap the router in an origin validator. A cross-origin attacker could mount any LiveView and dispatch any event from a victim's browser. Now the consumer rejects disallowed origins with close code 4403 before accepting the handshake, and DjustMiddlewareStack wraps its inner application in channels.security.websocket.AllowedHostsOriginValidator by default (defense in depth). Missing Origin is still allowed so non-browser clients (curl, test WebsocketCommunicator) continue to work. Upgrade notes: ensure settings.ALLOWED_HOSTS does NOT contain * in production; if you need to opt out for a specific stack, use DjustMiddlewareStack(inner, validate_origin=False) (not recommended). Reported via external penetration test 2026-04-10. (#653)

  • Enforce login_required on HTTP GET path — Views with login_required = True rendered full HTML to unauthenticated users on the initial HTTP GET. The WebSocket connection was correctly rejected, but the pre-rendered page content was already visible. Now calls check_view_auth() before mount() on HTTP GET and returns 302 to LOGIN_URL. Also calls handle_params() after mount() on HTTP GET to match the WebSocket path's behavior, preventing state flash on URL-param-dependent views. (#636, fixes #633, #634)

Fixed

  • Prevent SynchronousOnlyOperation in PerformanceTracker.track_context_size — The tracker called sys.getsizeof(str(context)), which triggered QuerySet.__repr__() on any unevaluated querysets in the context dict. __repr__ calls list(self[:21]), evaluating the queryset against the database — raising SynchronousOnlyOperation in the async WebSocket path. Now uses a shallow per-value getsizeof sum that does not invoke __repr__/__str__ on values, so lazy objects stay lazy. Size estimates are now slightly less precise (don't include recursive inner size) but safe in async contexts. (#651, fixes #649)

  • Apply RemoveChild patches before batched InsertChild in same parent groupapplyPatches in client.js:1379-1440 was filtering InsertChild patches out of each parent group and applying them via DocumentFragment before iterating the group for the RemoveChild patches in that same parent, violating the top-level Remove → Insert phase order. This was latent for keyed content (monotonic dj-ids meant removes still found targets by ID), but fired for <!--dj-if--> placeholder comments — they have no dj-id (only elements get IDs), so their removes fall back to index-based lookup, and by the time the removes ran, the batched inserts had already prepended the new content and shifted indices. The removes then deleted the just-inserted content, leaving empty tab content on multi-tab views (symptom: NYC Claims tab switches showing blank content after the first switch). Fix: split each parent group into non-Insert vs Insert lists, apply all non-Insert patches first in their phase-sorted order, then batch the inserts. (#643, fixes #641, closes #642)

  • dj-patch on <a> tags uses href when attribute value is empty — Boolean dj-patch on anchor elements (<a href="?tab=docs" dj-patch>) was resolving to the current URL instead of the href destination. Now falls back to el.getAttribute('href') when dj-patch is empty and the element is <a>. (#640)

  • Normalize Model instances in render_full_template before passing to Rust — Django FK fields are class-level descriptors not present in __dict__. Rust's FromPyObject extracts __dict__ which has claimant_id=1 (raw FK int) instead of the related object. Now always calls normalize_django_value() on pre-serialized context so FK relationships are resolved via getattr() and traversable with dot notation ({{ claim.claimant.first_name }}). (#639)

  • Render Django Form/BoundField to SafeString HTML in template context{{ form.field_name }} rendered as empty string because the Rust renderer extracted Form.__dict__ which doesn't contain computed BoundField attributes. Now pre-renders Form and BoundField objects to SafeString HTML via widget.render() in all four code paths (serialization, template serialization, template rendering, and LiveView state sync). (#631, fixes #621)

  • Correct has_ids attribute name in WebSocket mount responsewebsocket.py checked for "data-dj-id=" but the Rust renderer emits "dj-id=" attributes. This caused _stampDjIds() to be skipped on pre-rendered pages, breaking VDOM patches for large content swaps (e.g. tab switching) while small patches still worked. The SSE path already had the correct check. (#630, fixes #629)

  • Sync input .value from attribute after innerHTML/VDOM patch — When navigating backward in a multi-step wizard, text input values were not visually restored even though the server sent correct VDOM patches. setAttribute('value', x) only updates the HTML attribute (defaultValue), not the .value DOM property. Now syncs .value from the attribute in preserveFormValues(), broadcast patches, and morphElement(). Skips focused inputs, checkboxes, radios, and file inputs. (#625, fixes #624)

[0.4.0] - 2026-03-27

Security

  • Fix 25 CodeQL code-scanning alerts in client.js and debug-panel.js — Added UNSAFE_KEYS guard to VDOM SetAttr/RemoveAttr patches (rejects __proto__, constructor, prototype keys), replaced direct property assignment with Object.defineProperty() in debug panel state cloning, converted template literal logs to format strings to prevent log injection, and added XSS suppression comments for trusted server-rendered HTML. (#597)

Removed

  • whitenoise dependency — djust's ASGIStaticFilesHandler in djust.asgi.get_application() already handles static file serving at the ASGI layer, making WhiteNoise middleware redundant. Removed whitenoise from dependencies, scaffolded projects, and the demo project. Removed system check C006 (daphne without WhiteNoise). (#584)

Added

  • {% dj_flash %} template tag in Rust renderer — Registered DjFlashTagHandler so the flash container renders correctly when templates are processed by the Rust engine. Previously, the tag was only registered as a Django template tag and silently dropped by the Rust renderer. (#590)

  • Navigation lifecycle events and CSS classdjust:navigate-start / djust:navigate-end CustomEvents and .djust-navigating CSS class on [dj-root] during dj-navigate transitions. Enables CSS-only page transitions without monkey-patching pageLoading. (#585)

  • manage.py djust_doctor diagnostic command -- checks Rust extension, Python/Django versions, Channels, Redis, templates, static files, routing, and ASGI server in one command. Supports --json, --quiet, --check NAME, and --verbose flags.

  • Enhanced VDOM patch error messages -- patch failures now include patch type, dj-id, parent element info, and suggested causes (third-party DOM modification, {% if %} block changes). In DEBUG_MODE, a console group with full patch detail is shown. Batch failure summaries include which patch indices failed.

  • DEBUG-mode enriched WebSocket errors -- send_error includes debug_detail (unsanitized message), traceback (last 3 frames), and hint (actionable suggestion) when settings.DEBUG=True. handle_mount lists available LiveView classes when class lookup fails.

  • Debug panel warning interceptor -- intercepts console.warn calls matching [LiveView] prefix and surfaces them as a warning badge on the debug button. Configurable auto-open via LIVEVIEW_CONFIG.debug_auto_open_on_error.

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

  • Form recovery on reconnect — After WebSocket reconnects, form fields with dj-change or dj-input automatically fire change events to restore server state. Compares DOM values against server-rendered defaults and only fires for fields that differ. Use dj-no-recover to opt out individual fields. Fields inside dj-auto-recover containers are skipped (custom handler takes precedence). Works over both WebSocket and SSE transports.

  • Reconnection backoff with jitter — Exponential backoff with random jitter (AWS full-jitter strategy) prevents thundering herd on server restart. Min delay 500ms, max delay 30s, increased from 5 to 10 max attempts. Attempt count shown in reconnection banner (dj-reconnecting-banner CSS class) and exposed via data-dj-reconnect-attempt attribute and --dj-reconnect-attempt CSS custom property on <body>. Banner and attributes cleared on successful reconnect or intentional disconnect.

  • page_title / page_meta dynamic document metadata — Update document.title and <meta> tags from any LiveView handler via property setters (self.page_title = "...", self.page_meta = {"description": "..."}). Uses side-channel WebSocket messages (no VDOM diff needed). Supports og: and twitter: meta tags with correct property attribute. Works over both WebSocket and SSE transports.

  • dj-copy enhancements — Selector-based copy (dj-copy="#code-block" copies the element's textContent), configurable feedback text (dj-copy-feedback="Done!"), CSS class feedback (dj-copy-class adds a custom class for 2s, default dj-copied), and optional server event (dj-copy-event="copied" fires after successful copy for analytics). Backward compatible with existing literal copy behavior.

  • dj-auto-recover attribute for reconnection recovery — After WebSocket reconnects, elements with dj-auto-recover="handler_name" automatically fire a server event with serialized DOM state (form field values and data-* attributes from the container). Enables the server to restore custom state lost during disconnection. Does not fire on initial page load. Supports multiple independent recovery elements per page.

  • dj-debounce / dj-throttle HTML attributes — Apply debounce or throttle to any dj-* event attribute (dj-click, dj-change, dj-input, dj-keydown, dj-keyup) directly in HTML: <button dj-click="search" dj-debounce="300">. Takes precedence over data-debounce/data-throttle. Supports dj-debounce="blur" to defer until element loses focus (Phoenix parity). dj-debounce="0" disables default debounce on dj-input. Each element gets its own independent timer.

  • Connection state CSS classesdj-connected and dj-disconnected classes are automatically applied to <body> based on WebSocket/SSE transport state. Enables CSS-driven UI feedback for connection status (e.g., dimming content, showing offline banners). Both classes are removed on intentional disconnect (TurboNav). Phoenix LiveView's phx-connected/phx-disconnected equivalent.

  • dj-cloak attribute for FOUC prevention — Elements with dj-cloak are hidden (display: none !important) until the WebSocket/SSE mount response is received, preventing flash of unconnected content. CSS is injected automatically by client.js — no user stylesheet changes needed. Phoenix LiveView's phx-no-feedback equivalent.

  • Page loading bar for navigation transitions — NProgress-style thin loading bar at the top of the page during TurboNav and live_redirect navigation. Always active by default. Exposed as window.djust.pageLoading with start(), finish(), and enabled for manual control. Disable via window.djust.pageLoading.enabled = false or CSS override.

  • dj-scroll-into-view attribute for auto-scroll on render — Elements with dj-scroll-into-view are automatically scrolled into view after DOM updates (mount, VDOM patch). Supports scroll behavior options: "" (smooth/nearest, default), "instant", "center", "start", "end". One-shot per DOM node — uses WeakSet tracking so the same element isn't re-scrolled on every patch, but VDOM-replaced fresh nodes scroll correctly.

  • dj-window-* / dj-document-* event scoping — Bind event listeners on window or document while using the declaring element for context extraction (component_id, dj-value-* params). Supports dj-window-keydown, dj-window-keyup, dj-window-scroll, dj-window-click, dj-window-resize, dj-document-keydown, dj-document-keyup, dj-document-click. Key modifier filtering (e.g., dj-window-keydown.escape="close_modal") works the same as dj-keydown. Scroll and resize events default to 150ms throttle. Phoenix LiveView's phx-window-* equivalent, plus dj-document-* as a djust extension.

  • dj-click-away attribute — Fire a server event when the user clicks outside an element: <div dj-click-away="close_dropdown">. Uses capture-phase document listener so stopPropagation() inside the element doesn't prevent detection. Supports dj-confirm for confirmation dialogs and dj-value-* params from the declaring element.

  • dj-shortcut attribute for declarative keyboard shortcuts — Bind keyboard shortcuts on any element with modifier key support: <div dj-shortcut="ctrl+k:open_search:prevent, escape:close_modal">. Supports ctrl, alt, shift, meta modifiers, comma-separated multiple bindings, and prevent modifier to suppress browser defaults. Shortcuts are automatically skipped when the user is typing in form inputs (override with dj-shortcut-in-input attribute). Event params include key, code, and shortcut (the matched binding string).

  • _target param in form change/input events — When multiple form fields share one dj-change or dj-input handler, the _target param now includes the triggering element's name (or id, or null), letting the server know which field changed. For dj-submit, includes the submitter button's name if available. Matches Phoenix LiveView's _target convention.

  • dj-disable-with attribute for submit buttons — Automatically disable submit buttons during form submission and replace their text with a loading message: <button type="submit" dj-disable-with="Saving...">Save</button>. Prevents double-submit and gives instant visual feedback. Works with both dj-submit forms and dj-click buttons. Original text is restored after server response.

  • dj-lock attribute for concurrent event prevention — Disable an element until its event handler response arrives from the server: <button dj-click="save" dj-lock>Save</button>. Prevents rapid double-clicks from triggering duplicate server events. For non-form elements (e.g., <div>), applies a djust-locked CSS class instead of the disabled property. All locked elements are unlocked on server response.

  • dj-mounted event for element lifecycle — Fire a server event when an element with dj-mounted="handler_name" enters the DOM after a VDOM patch: <div dj-mounted="on_chart_ready" dj-value-chart-type="bar">. Does not fire on initial page load (only after subsequent patches). Includes dj-value-* params from the mounted element. Uses a WeakSet to prevent duplicate fires for the same DOM node.

  • Priority-aware event queue for broadcast and async updates — Server-initiated broadcasts (server_push) and async completions (_run_async_work) are now tagged with source="broadcast" and source="async" respectively, and the client buffers them during pending user event round-trips (same as tick buffering from #560). server_push now acquires the render lock and yields to in-progress user events to prevent version interleaving. Client-side pending event tracking upgraded from single ref to Set-based tracking, supporting multiple concurrent pending events. Buffer flushes only when all pending events resolve.

  • manage.py djust_gen_live — Model-to-LiveView scaffolding generator — Generate a complete CRUD LiveView scaffold from a model name and field definitions: python manage.py djust_gen_live blog Post title:string body:text. Creates views.py (with @event_handler CRUD operations), urls.py (using live_session() routing), HTML template (with dj-* directives), and tests.py. Supports --dry-run, --force, --no-tests, --api (JSON mode) options. Handles all Django field types including FK relationships. Search uses Q objects for OR logic across text fields.

  • on_mount hooks for cross-cutting mount logic — Module-level hooks that run on every LiveView mount, declared via @on_mount decorator and on_mount class attribute. Use cases: authentication checks, telemetry, tenant resolution, feature flags. Hooks run after auth checks, before mount(). Return a redirect URL string to halt the mount pipeline. Hooks are inherited via MRO (parent-first, deduplicated). Includes V009 system check for validation. Phoenix on_mount v0.17+ parity.

  • put_flash(level, message) and clear_flash() for ephemeral flash notifications — Phoenix put_flash parity. Queue transient messages (info, success, warning, error) from any event handler; they are flushed to the client over WebSocket/SSE after each response. Includes {% dj_flash %} template tag with auto-dismiss and ARIA role="status" / role="alert" support. (#568)

  • handle_params called on initial mounthandle_params(params, uri) is now invoked after mount() on the initial WebSocket connect, not just on subsequent URL changes. This matches Phoenix LiveView's handle_params/3 contract and eliminates the need to duplicate URL-parsing logic between mount() and handle_params(). Views that don't override handle_params are unaffected (default is a no-op).

  • dj-value-* — Static event parameters — Pass static values alongside events without data-* attributes or hidden inputs: <button dj-click="delete" dj-value-id:int="{{ item.id }}" dj-value-type="soft">. Supports type-hint suffixes (:int, :float, :bool, :json, :list), kebab-to-snake_case conversion, and prototype pollution prevention. Works with all event types: dj-click, dj-submit, dj-change, dj-input, dj-keydown, dj-keyup, dj-blur, dj-focus, dj-poll. Phoenix LiveView's phx-value-* equivalent.

Fixed

  • True/False/None literals resolved as empty string in custom tag argsget_value() didn't recognize Python boolean/None literals, so {% tag show_labels=False %} produced show_labels= (empty string) instead of show_labels=False. Now handles True/true, False/false, and None/none as literal values. (#602)

  • Flash and page_metadata not delivered over HTTP POST fallbackput_flash() and page_title/page_meta side-channel commands were only flushed over WebSocket. HTTP POST responses now drain _pending_flash and _pending_page_metadata and include them as _flash and _page_metadata arrays in the JSON response. (#590)

  • Custom tag args containing lists/objects serialized as [List]/[Object]Value::List and Value::Object in custom tag arguments were stringified via the Display trait, destroying structured data before it reached Python handlers. Now serialized as JSON via serde_json. (#589)

  • Django filters not applied in custom tag arguments{% tag key=var|length %} rendered the literal string instead of the computed value because arg resolution used context.get() (plain lookup) instead of get_value() (filter-aware). (#591)

  • {% if %} inside HTML tag after {{ variable }} emits <!--dj-if--> commentis_inside_html_tag() only checked the immediately preceding token, missing tag context when {{ variable }} tokens appeared between the tag opening and {% if %}. Added is_inside_html_tag_at() that scans all preceding tokens. (#580)

  • Tick/event version mismatch silently drops user input — Server-initiated ticks could collide with user events, causing VDOM version divergence that silently discarded patches. Added server-side asyncio.Lock to serialize tick and event render operations, priority yielding so ticks skip during user events, client-side tick patch buffering during pending event round-trips, and monotonic event ref tracking for request/response matching. (#560)

  • Focus lost during VDOM patches — When the server pushed VDOM patches (e.g., updating a counter while the user was typing), the focused input/textarea lost focus, cursor position, selection range, and scroll position. Added saveFocusState() / restoreFocusState() around the applyPatches() cycle to capture and restore activeElement, selectionStart/selectionEnd, and scrollTop/scrollLeft. Element matching uses id → name → dj-id → positional index. Broadcast (remote) updates correctly skip focus restoration.

  • VDOM patching fails when {% if %} blocks add/remove DOM elements — Comment node placeholders (<!--dj-if-->) emitted by the Rust template engine were excluded from client-side child index resolution (getSignificantChildren and getNodeByPath), causing path traversal errors and silent patch failures. Also added #comment handling to createNodeFromVNode so comment placeholders can be correctly created during InsertChild patches. (#559)

[0.3.8] - 2026-03-19

Fixed

  • Tick auto-refresh causes VDOM version mismatch, silently drops user events_run_tick always called render_with_diff() even when handle_tick() made no state changes, incrementing the VDOM version on every tick. When a user event interleaved with a tick, the client and server versions diverged, causing all subsequent patches to be silently discarded. Tick now uses _snapshot_assigns to skip render when no public assigns changed. (#560)
  • WS VDOM cache key collision across tabs — All WebSocket LiveViews shared a single RustLiveView cache slot keyed by /ws/live/, causing multi-tab sessions to overwrite each other's compiled templates. Cache key now uses request.path (the actual page URL) so each view gets its own VDOM baseline. (#561)
  • Canvas width/height cleared during html_update morphmorphElement removed attributes absent from server HTML, resetting canvas 2D contexts and blanking Chart.js charts. Canvas width and height are now preserved during attribute sync. (#561)
  • _force_full_html not checked in handle_url_change — Views that set _force_full_html = True in handle_params (e.g., when {% for %} loop lengths change) still received VDOM patches instead of full HTML. The flag is now checked after render_with_diff() in both handle_event and handle_url_change. (#559, #561)

Added

  • dj-patch on selects/inputs uses WS url_change — Select and input elements with dj-patch now update via pushState + WebSocket url_change instead of full page reload. A delegated document change listener survives DOM replacement by morphdom. dj-patch-reload attribute remains as an opt-in escape hatch for full page navigation. (#561)

[0.3.7] - 2026-03-16

Fixed

  • FormMixin: serialization, event handling, and ModelForm support — Fixed 6 issues blocking production use of FormMixin with ModelForm over WebSocket: added @event_handler to submit_form() and validate_field(); renamed form_instance to private _form_instance with backward-compatible property; store model_pk/model_label as public attributes for re-hydration after WS session restore; sync form_data from saved instance after form_valid(); use FK PK instead of related object; auto-populate form_choices with serializable tuples. (#545)
  • dj-hook elements not re-initialized after html_update or html_recovery — When VDOM patches failed and djust fell back to full HTML replacement, updateHooks() was never called, leaving hook elements stale (charts showing old data, canvases empty). Added updateHooks() to all DOM replacement paths: html_update, html_recovery, TurboNav reinit, embedded view update, lazy hydration, and streaming updates. (#548)
  • __version__ not updated by make versionmake version only updated pyproject.toml and Cargo.toml but not the hardcoded __version__ in __init__.py files. djust.__version__ now stays in sync with the package version. (#547)

Changed

  • Extract reinitAfterDOMUpdate() to DRY up post-DOM-update calls — The repeated pattern of initReactCounters() + initTodoItems() + bindLiveViewEvents() + updateHooks() across 10+ call sites is now a single function. New DOM replacement paths only need one call. (#549)
  • Extract addEventContext() to consolidate component/embedded view ID extraction — The 8-line getComponentId/getEmbeddedViewId pattern appeared 4 times in event binding; now a single helper. (#551)
  • Extract isWSConnected() to replace WebSocket state guard chains — The liveViewWS && liveViewWS.ws && liveViewWS.ws.readyState === WebSocket.OPEN pattern appeared across 4 files; now a single predicate. (#552)
  • Extract clearOptimisticPending() to consolidate CSS class cleanup — The querySelectorAll('.optimistic-pending') removal loop appeared 4 times across 2 files; now a single function. (#553)
  • Standardize DJUST_CONFIG access via get_djust_config() — Replaced 10+ inline getattr(settings, "DJUST_CONFIG", {}) try/except blocks across tenants, PWA, and storage modules with a single get_djust_config() helper in config.py. (#554)
  • Extract generic BackendRegistry class — The duplicated lazy-init / set / reset pattern in state_backends/registry.py and backends/registry.py now delegates to a shared BackendRegistry class in utils.py. (#555)
  • Extract is_model_list() helper — The repeated isinstance(value, list) and value and isinstance(value[0], models.Model) check is now a single is_model_list() function in utils.py, used in mixins/context.py and mixins/request.py. (#556)

[0.3.6] - 2026-03-14

Breaking Changes

  • model.id now returns the native type, not a string_serialize_model_safely() previously wrapped obj.pk with str() when producing the "id" key, causing template comparisons like {% if edit_id == todo.id %} to fail silently when edit_id was an integer. model.id now matches model.pk and returns the native Python type (e.g. int, UUID). Migration: if your templates or event handlers compare model.id against string literals or string-typed variables, update them to use the native type. PR #262 fixed .pk; this PR (#472) completes the fix for .id.

Fixed

  • Skip redundant mount() on WebSocket connect for pre-rendered pages — When the client sends has_prerendered=true on WS connect and saved state exists in the session (written during the HTTP GET), the view's attributes are restored from session instead of re-running mount(). This eliminates the double page-load cost for views with expensive mount() implementations (e.g. directory scans, API calls). Falls back to calling mount() normally when no saved state is found. _ensure_tenant() is now called unconditionally before the restore/mount decision, fixing a regression where multi-tenant views had self.tenant=None on WS connect for pre-rendered pages. (#542)
  • djust cache --all now correctly clears all sessions on the Redis backend — The CLI called cleanup_expired(ttl=0) to force-clear sessions, but the semantics of ttl=0 changed in 0.3.5 to mean "never expire". The command now calls the explicit delete_all() method, which uses a Redis pipeline for an efficient single round-trip bulk delete. (#409)
  • dj-params attribute no longer silently dropped — Between 0.3.2 and 0.3.6rc2, dj-params was removed from the client event-binding code. Templates using dj-params='{"key": value}' continued to fire click events but the server received params: {}. The attribute is now read and merged into the params object for backward compatibility. A console.warn is emitted in debug mode (globalThis.djustDebug) to notify developers to migrate. (#469)
  • Prefetch Set not cleared on SPA navigation — The client-side _prefetched Set persisted across live_redirect navigations, preventing links on the new view from being prefetched. Added clear() to window.djust._prefetch and call it in handleLiveRedirect() so each SPA navigation starts with a fresh prefetch state. (#402)
  • Auto-reload on unrecoverable VDOM state — When VDOM patch recovery fails because recovery HTML is unavailable (e.g. after server restart), the client now auto-reloads the page instead of showing a confusing error overlay. The server sends recoverable: false to signal the client. (#421)
  • {% djust_pwa_head %} and other custom tags with quoted arguments containing spaces now render correctly — The Rust template lexer used split_whitespace() to tokenize tag arguments, which broke quoted values like name="My App" into separate tokens (name="My and App"). This caused the downstream Python handler to receive malformed arguments, silently returning empty output. Replaced with a quote-aware splitter (split_tag_args) that preserves quoted strings as single arguments. (#419)
  • {% load %} tags stripped during template inheritance, breaking inclusion tags — The Rust parser treated {% load %} as Node::Comment, which nodes_to_template_string() discarded during inheritance reconstruction. When the resolved template was re-parsed, custom tags that relied on Django tag libraries (e.g. {% djust_pwa_head %}) could silently fail. Fixed by adding a dedicated Node::Load variant that preserves library names through reconstruction. Also improved _render_django_tag() error handling: failures now log a full traceback via logger.exception() and return a visible HTML comment instead of an empty string. (#418)
  • Checkbox/radio checked and <option> selected state not updated by VDOM patchesSetAttr and RemoveAttr patches only called setAttribute/removeAttribute, which updates the HTML attribute but not the DOM property. After user interaction the browser separates the two, so server-driven state changes via dj-click had no visible effect on checkboxes, radios, or select options. Fixed by syncing the DOM property alongside the attribute. Also fixed createNodeFromVNode to set .checked/.selected when creating new elements. (#422)
  • SESSION_TTL=0 breaks all event handling (no DOM patches)cleanup_expired() methods in both InMemoryStateBackend and RedisStateBackend now treat TTL ≤ 0 as "never expire". Previously SESSION_TTL=0 caused cutoff = time.time() - 0, making all sessions appear expired, deleting them immediately, and leaving no state for VDOM patches. (#395)
  • WebSocket session extraction crashes on Django Channels LazyObject — Replaced hasattr(scope_session, "session_key") with getattr(scope_session, "session_key", None) in the consumer's request context builder. hasattr() on a Django Channels LazyObject can raise non-AttributeError exceptions during lazy evaluation, causing the consumer to crash silently. (#396)

Deprecated

  • dj-params JSON blob attribute — Use individual data-* attributes with optional type-coercion suffixes instead. dj-params will be removed in a future release.

    Migration guide (0.3.2 → 0.3.6):

    <!-- Before (0.3.2) -->
    <button dj-click="start_edit" dj-params='{"todo_id": {{ todo.id }}}'>Edit</button>
    <button dj-click="set_filter" dj-params='{"filter_value": "all"}'>All</button>
    
    <!-- After (0.3.6+) -->
    <button dj-click="start_edit" data-todo-id:int="{{ todo.id }}">Edit</button>
    <button dj-click="set_filter" data-filter-value="all">All</button>
    

    Type-coercion suffixes: :int, :float, :bool, :json. Kebab-case attribute names are auto-converted to snake_case for server handler parameters.

Added

  • djust-deploy CLI — new python/djust/deploy_cli.py module providing deployment commands for djustlive.com. Available via the djust-deploy entry point after installation. (#437)
    • djust-deploy login — prompts for email/password, authenticates against djustlive.com, and stores the token in ~/.djustlive/credentials (mode 0o600)
    • djust-deploy logout — calls the server logout endpoint and removes the local credentials file
    • djust-deploy status [project] — fetches current deployment state; optionally filtered by project slug
    • djust-deploy deploy <project-slug> — validates the git working tree is clean, triggers a production deployment, and streams build logs to stdout
    • --server flag / DJUST_SERVER env var to override the default server URL (https://djustlive.com)
  • TypeScript type stubs updatedDjustStreamOp now includes "done" and "start" operation types and an optional mode field ("append" | "replace" | "prepend"). getActiveStreams() return type changed from Map to Record.
  • .flex-between CSS utility class — Added to demo project's utilities.css for laying out flex children horizontally with space-between. Use on card headers or any flex container that needs a title on the left and action widget on the right. (#397)
  • Debug toolbar state size visualization — New "Size Breakdown" table in State tab shows per-variable memory and serialized byte sizes with human-readable formatting (B/KB/MB). Added _debug_state_sizes() method to PostProcessingMixin included in both mount and event debug payloads. (#459)
  • Debug panel TurboNav persistence — Event, patch, network, and state history now persist across TurboNav navigation via sessionStorage (30s window). Panel state restores on next page if navigated within 30 seconds. (#459)
  • TurboNav integration guide — Comprehensive guide covering setup, navigation lifecycle, inline script handling, known caveats, and design decisions: docs/guides/turbonav-integration.md. (#459)
  • Debug panel search extended to Network and State tabs — The search bar in the debug panel now filters across all data tabs. The Network tab shows a N / total count label when a query narrows the message list (#530). The State tab filters history entries by trigger, event name, and serialized state content, with the same N / total count label (#520). Overlapping nameFilter and searchQuery on the Events tab now correctly apply AND semantics (#532). (#541)

[0.3.6rc4] - 2026-03-13

Fixed

  • Skip redundant mount() on WebSocket connect for pre-rendered pages — When the client sends has_prerendered=true on WS connect and saved state exists in the session (written during the HTTP GET), the view's attributes are restored from session instead of re-running mount(). This eliminates the double page-load cost for views with expensive mount() implementations (e.g. directory scans, API calls). Falls back to calling mount() normally when no saved state is found. _ensure_tenant() is now called unconditionally before the restore/mount decision, fixing a regression where multi-tenant views had self.tenant=None on WS connect for pre-rendered pages. (#542)

[0.3.6rc3] - 2026-03-13

Breaking Changes

  • model.id now returns the native type, not a string_serialize_model_safely() previously wrapped obj.pk with str() when producing the "id" key, causing template comparisons like {% if edit_id == todo.id %} to fail silently when edit_id was an integer. model.id now matches model.pk and returns the native Python type (e.g. int, UUID). Migration: if your templates or event handlers compare model.id against string literals or string-typed variables, update them to use the native type. PR #262 fixed .pk; this PR (#472) completes the fix for .id.

Fixed

  • djust cache --all now correctly clears all sessions on the Redis backend — The CLI called cleanup_expired(ttl=0) to force-clear sessions, but the semantics of ttl=0 changed in 0.3.5 to mean "never expire". The command now calls the explicit delete_all() method, which uses a Redis pipeline for an efficient single round-trip bulk delete. (#409)
  • dj-params attribute no longer silently dropped — Between 0.3.2 and 0.3.6rc2, dj-params was removed from the client event-binding code. Templates using dj-params='{"key": value}' continued to fire click events but the server received params: {}. The attribute is now read and merged into the params object for backward compatibility. A console.warn is emitted in debug mode (globalThis.djustDebug) to notify developers to migrate. (#469)
  • Prefetch Set not cleared on SPA navigation — The client-side _prefetched Set persisted across live_redirect navigations, preventing links on the new view from being prefetched. Added clear() to window.djust._prefetch and call it in handleLiveRedirect() so each SPA navigation starts with a fresh prefetch state. (#402)
  • Auto-reload on unrecoverable VDOM state — When VDOM patch recovery fails because recovery HTML is unavailable (e.g. after server restart), the client now auto-reloads the page instead of showing a confusing error overlay. The server sends recoverable: false to signal the client. (#421)
  • {% djust_pwa_head %} and other custom tags with quoted arguments containing spaces now render correctly — The Rust template lexer used split_whitespace() to tokenize tag arguments, which broke quoted values like name="My App" into separate tokens (name="My and App"). This caused the downstream Python handler to receive malformed arguments, silently returning empty output. Replaced with a quote-aware splitter (split_tag_args) that preserves quoted strings as single arguments. (#419)
  • {% load %} tags stripped during template inheritance, breaking inclusion tags — The Rust parser treated {% load %} as Node::Comment, which nodes_to_template_string() discarded during inheritance reconstruction. When the resolved template was re-parsed, custom tags that relied on Django tag libraries (e.g. {% djust_pwa_head %}) could silently fail. Fixed by adding a dedicated Node::Load variant that preserves library names through reconstruction. Also improved _render_django_tag() error handling: failures now log a full traceback via logger.exception() and return a visible HTML comment instead of an empty string. (#418)
  • Checkbox/radio checked and <option> selected state not updated by VDOM patchesSetAttr and RemoveAttr patches only called setAttribute/removeAttribute, which updates the HTML attribute but not the DOM property. After user interaction the browser separates the two, so server-driven state changes via dj-click had no visible effect on checkboxes, radios, or select options. Fixed by syncing the DOM property alongside the attribute. Also fixed createNodeFromVNode to set .checked/.selected when creating new elements. (#422)
  • SESSION_TTL=0 breaks all event handling (no DOM patches)cleanup_expired() methods in both InMemoryStateBackend and RedisStateBackend now treat TTL ≤ 0 as "never expire". Previously SESSION_TTL=0 caused cutoff = time.time() - 0, making all sessions appear expired, deleting them immediately, and leaving no state for VDOM patches. (#395)
  • WebSocket session extraction crashes on Django Channels LazyObject — Replaced hasattr(scope_session, "session_key") with getattr(scope_session, "session_key", None) in the consumer's request context builder. hasattr() on a Django Channels LazyObject can raise non-AttributeError exceptions during lazy evaluation, causing the consumer to crash silently. (#396)

Deprecated

  • dj-params JSON blob attribute — Use individual data-* attributes with optional type-coercion suffixes instead. dj-params will be removed in a future release.

    Migration guide (0.3.2 → 0.3.6):

    <!-- Before (0.3.2) -->
    <button dj-click="start_edit" dj-params='{"todo_id": {{ todo.id }}}'>Edit</button>
    <button dj-click="set_filter" dj-params='{"filter_value": "all"}'>All</button>
    
    <!-- After (0.3.6+) -->
    <button dj-click="start_edit" data-todo-id:int="{{ todo.id }}">Edit</button>
    <button dj-click="set_filter" data-filter-value="all">All</button>
    

    Type-coercion suffixes: :int, :float, :bool, :json. Kebab-case attribute names are auto-converted to snake_case for server handler parameters.

Added

  • djust-deploy CLI — new python/djust/deploy_cli.py module providing deployment commands for djustlive.com. Available via the djust-deploy entry point after installation. (#437)
    • djust-deploy login — prompts for email/password, authenticates against djustlive.com, and stores the token in ~/.djustlive/credentials (mode 0o600)
    • djust-deploy logout — calls the server logout endpoint and removes the local credentials file
    • djust-deploy status [project] — fetches current deployment state; optionally filtered by project slug
    • djust-deploy deploy <project-slug> — validates the git working tree is clean, triggers a production deployment, and streams build logs to stdout
    • --server flag / DJUST_SERVER env var to override the default server URL (https://djustlive.com)
  • TypeScript type stubs updatedDjustStreamOp now includes "done" and "start" operation types and an optional mode field ("append" | "replace" | "prepend"). getActiveStreams() return type changed from Map to Record.
  • .flex-between CSS utility class — Added to demo project's utilities.css for laying out flex children horizontally with space-between. Use on card headers or any flex container that needs a title on the left and action widget on the right. (#397)
  • Debug toolbar state size visualization — New "Size Breakdown" table in State tab shows per-variable memory and serialized byte sizes with human-readable formatting (B/KB/MB). Added _debug_state_sizes() method to PostProcessingMixin included in both mount and event debug payloads. (#459)
  • Debug panel TurboNav persistence — Event, patch, network, and state history now persist across TurboNav navigation via sessionStorage (30s window). Panel state restores on next page if navigated within 30 seconds. (#459)
  • TurboNav integration guide — Comprehensive guide covering setup, navigation lifecycle, inline script handling, known caveats, and design decisions: docs/guides/turbonav-integration.md. (#459)
  • Debug panel search extended to Network and State tabs — The search bar in the debug panel now filters across all data tabs. The Network tab shows a N / total count label when a query narrows the message list (#530). The State tab filters history entries by trigger, event name, and serialized state content, with the same N / total count label (#520). Overlapping nameFilter and searchQuery on the Events tab now correctly apply AND semantics (#532). (#541)

[0.3.5] - 2026-03-05

Added

  • djust-deploy CLI — new python/djust/deploy_cli.py module providing deployment commands for djustlive.com. Install with pip install djust[deploy]. Available via the djust-deploy entry point:
    • djust-deploy login — prompts for email/password, authenticates against djustlive.com, and stores the token in ~/.djustlive/credentials (mode 0o600)
    • djust-deploy logout — calls the server logout endpoint and removes the local credentials file
    • djust-deploy status [project] — fetches current deployment state; optionally filtered by project slug
    • djust-deploy deploy <project-slug> — validates the git working tree is clean, triggers a production deployment, and streams build logs to stdout

Fixed

  • dj-hook elements now initialize after dj-navigate navigationupdateHooks() is called after live_redirect_mount replaces DOM content via WebSocket and SSE mount handlers. Previously, hook lifecycle callbacks (mounted(), destroyed()) were skipped after client-side navigation, leaving hook-dependent elements (e.g., Chart.js canvases) uninitialized. (#408)
  • Event handler exceptions now logged with full traceback in production — Previously, handle_exception() only logged the exception class name (e.g. ValueError) when DEBUG=False, hiding the error message and stack trace. Now logs type, message, and traceback at ERROR level regardless of DEBUG mode. Client responses remain generic in production. (#415)
  • DJE-053 no longer fires as a warning for idempotent event handlers — When an @event_handler runs successfully but produces no DOM changes (e.g. toggle clicked in target state, debounced input with unchanged results, side-effect-only handlers), the empty diff is now silently dropped at DEBUG level rather than logged as a WARNING. This matches Phoenix LiveView behaviour. The WARNING-level DJE-053 is preserved for genuine VDOM failures (patches=None), which fall back to a full HTML update and risk losing event listeners. (#415)

[0.3.5rc2] - 2026-03-04

Fixed

  • VDOM patching with conditional {% if %} blocksInsertChild and RemoveChild patches now include ref_d and child_d fields for ID-based DOM resolution, preventing stale-index mis-targeting when {% if %} blocks add or remove elements that shift sibling positions. Falls back to index-based resolution for backwards compatibility. (#410)

[0.3.5rc1] - 2026-02-26

Added

  • Type stubs for Rust-injected LiveView methods.pyi stubs for live_redirect, live_patch, push_event, stream, and related methods so mypy/pyright catch typos at lint time. (#390)
  • Navigation Patterns guide — Documents when to use dj-navigate vs live_redirect vs live_patch. (#390)
  • Testing guide — Django testing best practices and pytest setup for djust applications. (#390)
  • System checks reference — New docs/system-checks.md covering all 37 check IDs (C/V/S/T/Q) with severity, detection method, suppression patterns, and known false positives. (#398)

Security

  • mark_safe(f"...") eliminated in core frameworkcomponents/base.py now uses format_html() to avoid XSS risk in component rendering. (#390)
  • Exception details no longer exposed in productionrender_template() previously returned f"<div>Error: {e}</div>" unconditionally, leaking internal Rust template engine details. Now returns a generic message in production; error details are only shown when settings.DEBUG = True. (#385)
  • Playground XSS fixed — Replaced innerHTML assignment with a sandboxed iframe for user-editable preview content. (#384)
  • Prototype pollution guard — Added safeguards against prototype pollution in client-side JS. (#384)

Fixed

  • {% if %} inside attribute values no longer shifts VDOM path indices — Conditional attribute fragments were causing off-by-one errors in VDOM diffing. (#390)
  • super().__init__() added to component and backend subclassesTenantAwareRedisBackend, TenantAwareMemoryBackend, and several example components were missing super().__init__() calls, causing MRO issues. (#386)
  • Unused escape import removed from data_table.py — CodeQL alert resolved. (#387)
  • render_full_template signature mismatch fixedno_template_demo.py override now correctly accepts serialized_context. (#387)
  • V004 false positives on lifecycle methodshandle_params(), handle_disconnect(), handle_connect(), and handle_event() no longer incorrectly trigger the V004 system check. (#398)
  • T013 false positives for {{ view_path }}dj-view="{{ view_path }}" (Django template variable injection) is now correctly recognised as valid by T013. (#398)
  • V008 false positives for -> str-annotated functions — Functions with primitive return-type annotations (e.g. -> str, -> int) no longer trigger V008 when their result is assigned in mount(). (#398)
  • Test isolationtest_checks.py and double_bind.test.js no longer fail when run as part of the full suite. (#390)

[0.3.4] - 2026-02-24

Stable release — promotes 0.3.3rc1 through 0.3.3rc3. All changes below were present in the RC series; this entry summarises them for the stable changelog.

Added

  • 6 new Django template tags in Rust renderer{% widthratio %}, {% firstof %}, {% templatetag %}, {% spaceless %}, {% cycle %}, {% now %}. (#329)
  • System checks djust.T011 / T012 / T013 — Warns at startup for unsupported Rust template tags, missing dj-view, and invalid dj-view paths. (#293, #329)
  • Deployment guides — Railway, Render, and Fly.io. (#247)
  • Navigation and LiveView invariants documentation. (#304, #316)

Fixed

  • #380: {% if %} in HTML attribute values no longer emits <!--dj-if--> comment — Produced malformed HTML (e.g. class="btn <!--dj-if-->"). Empty string is emitted instead; text-node VDOM anchor is unaffected. (#381)
  • #382: {% elif %} chains in attribute values propagate in_tag_context — All elif nodes in a chain now inherit the outer {% if %}'s attribute context. (#383)
  • {% if/else %} branches miscounting div depth in template extraction. (#365)
  • VDOM extraction used fully-merged {% extends %} document. (#366)
  • TypeError: Illegal invocation in debug panel on Chrome/Edge. (#367)
  • dj-patch('/') now correctly updates browser URL to root path. (#307)
  • live_patch routing restoredhandleNavigation dispatch now fires correctly. (#307)
  • T003 false positives eliminated{% include %} check now examines the include path, not whole-file content. (#331)

[0.3.3rc3] - 2026-02-24

Fixed

  • #382: {% elif %} inside HTML attribute values propagates in_tag_context — When {% if a %}...{% elif b %}...{% endif %} appears inside an attribute value and all conditions are false, the elif node previously emitted <!--dj-if--> (malformed HTML). Fixed by threading in_tag_context as a parameter into parse_if_block() so elif nodes inherit the outer if's attribute context. (#382)

[0.3.3rc2] - 2026-02-24

Fixed

  • {% if/else %} branches miscounting div depth in template extraction_extract_liveview_root_with_wrapper and the other extraction methods treated both branches of a {% if/else %} block as independent div opens, causing depth to never reach 0 when both branches opened a div sharing a single closing </div>. This caused the entire template to be returned as root, making the view non-reactive. Fixed with a shared _find_closing_div_pos() static method that uses a branch stack to restore depth at {% else %}/{% elif %} tags, so mutually-exclusive branches are counted as one open. (#365)
  • VDOM extraction used fully-merged {% extends %} document — For inherited templates, get_template() extracted the VDOM root from the fully-resolved document (base HTML + inlined blocks), which contains surrounding HTML that the depth counter could trip over. Now prefers the child template source when it contains dj-root/dj-view, which holds exactly the block content needed. Also fixes the exception fallback path: the raw child source (containing {% extends %}) was incorrectly stored in _full_template, causing render_full_template to attempt rendering a non-standalone template. (#366)
  • TypeError: Illegal invocation in debug panel on Chrome/Edge_hookExistingWebSocket called native WebSocket getter/setter functions via Function.prototype.call() from external code, which fails V8's brand check on IDL-generated bindings. Fixed by using normal property access (ws.onmessage) and assignment (ws.onmessage = handler) instead of desc.get/set.call(ws). (#367)

[0.3.3rc1] - 2026-02-21

Added

  • 6 new Django template tags in Rust renderer — Implemented {% widthratio %}, {% firstof %}, {% templatetag %}, {% spaceless %}, {% cycle %}, and {% now %} in the Rust template engine. These tags were previously rendered as HTML comments with warnings. (#329)
  • System check djust.T011 for unsupported template tags — Warns at startup when templates use Django tags not yet implemented in the Rust renderer (ifchanged, regroup, resetcycle, lorem, debug, filter, autoescape). Suppressible with {# noqa: T011 #}. (#329)
  • System check djust.T012 for missing dj-view — Detects templates that use dj-* event directives without a dj-view attribute, which would silently fail at runtime. (#293)
  • System check djust.T013 for invalid dj-view paths — Detects empty or malformed dj-view attribute values. (#293)
  • {% now %} supports 35+ Django date format specifiers — Including S (ordinal suffix), t (days in month), w/W (weekday/week number), L (leap year), c (ISO 8601), r (RFC 2822), U (Unix timestamp), and Django's special P format (noon/midnight).
  • Deployment guides — Added deployment documentation for Railway, Render, and Fly.io. (#247)
  • Navigation best practices documentation — Documented dj-patch vs dj-click for client-side navigation, with handle_params() patterns. (#304)
  • LiveView invariants documentation — Documented root container requirement and **kwargs convention for event handlers. (#316)

Fixed

  • #380: {% if %} inside HTML attribute values no longer emits <!--dj-if--> comment — When a {% if %} block with no else branch evaluates to false inside an HTML attribute value (e.g. class="btn {% if active %}active{% endif %}"), the Rust renderer now emits an empty string instead of the <!--dj-if--> VDOM placeholder. The placeholder is only meaningful as a DOM child node; inside an attribute it produced malformed HTML (e.g. class="btn <!--dj-if-->"). Text-node context is unaffected — the anchor comment is still emitted there for VDOM stability (fix for DJE-053 / #295).
  • False {% if %} blocks now emit <!--dj-if--> placeholder instead of empty string — Gives the VDOM diffing engine a stable DOM anchor to target when the condition later becomes true, resolving DJE-053 / issue #295.
  • dj-patch('/') now correctly updates the browser URL to the root path — Removed the url.pathname !== '/' guard in bindNavigationDirectives that prevented the browser URL from being updated when patching to /. The guard was silently ignoring root-path patches. (#307)
  • live_patch routing restored — handleNavigation dispatch now fires correctly — Fixed dict merge order in _flush_navigation so type: 'navigation' is no longer overwritten by **cmd. Added an action field to carry the nav sub-type (live_patch / live_redirect); handleNavigation now dispatches on data.action instead of data.type. Previously the client switch case 'navigation': never matched because type was being overwritten with "live_patch". Note: data.action || data.type fallback is kept for old JS clients that send messages without an action field — this fallback is planned for removal in the next minor release. (#307)
  • T003 false positives eliminated — The {% include %} check now examines the include path instead of the whole file content, preventing false warnings on templates that include SVGs or modals alongside dj-* directives. (#331)

[0.3.2] - 2026-02-18

Added

  • TypeScript definitions (djust.d.ts) — Comprehensive ambient TypeScript declaration file shipped with the Python package at static/djust/djust.d.ts. Covers: window.djust namespace, LiveViewWebSocket and LiveViewSSE transport classes, DjustHook lifecycle interface (mounted, beforeUpdate, updated, destroyed, disconnected, reconnected), DjustHookContext (this.el, this.pushEvent, this.handleEvent), dj-model binding types, streaming API types (DjustStreamMessage, DjustStreamOp), upload progress event types (DjustUploadEntry, DjustUploadConfig, DjustUploadProgressEventDetail), and the djust:upload:progress custom DOM event. Use via /// <reference path="..." /> or add to tsconfig.json.
  • Python type stubs (_rust.pyi) — PEP 561 compliant type stubs for the PyO3 Rust extension module (djust._rust). Covers all exported functions (render_template, render_template_with_dirs, diff_html, resolve_template_inheritance, fast_json_dumps, serialization helpers, tag handler registry) and classes (RustLiveView, SessionActorHandle, SupervisorStatsPy, and all 15 Rust UI components). Enables full IDE autocomplete and mypy type checking for the Rust extension.
  • SSE (Server-Sent Events) fallback transport — djust now automatically falls back to SSE when WebSocket is unavailable (corporate proxies, enterprise firewalls). Architecture: EventSource for server→client push, HTTP POST for client→server events. Transport negotiation is automatic: WebSocket is tried first; SSE activates after all reconnect attempts fail. Register the endpoint with path("djust/", include(djust.sse.sse_urlpatterns)) and include 03b-sse.js in your template. Feature limitations: no binary file uploads, no presence tracking, no actor-based state. See docs/sse-transport.md for full setup guide.
  • Type stub files (.pyi) for LiveView and mixins — Added PEP 561 compliant type stubs for NavigationMixin, PushEventMixin, StreamsMixin, StreamingMixin, and LiveView to enable IDE autocomplete and mypy type checking for runtime-injected methods like live_redirect, live_patch, push_event, stream, stream_insert, stream_delete, and stream_to. Includes py.typed marker file and comprehensive test suite.
  • @background decorator for async event handlers — New decorator that automatically runs the entire event handler in a background thread via start_async(). Simplifies syntax for long-running operations (AI generation, API calls, file processing) without needing explicit callback splitting. Can be combined with other decorators like @debounce. Task name is automatically set to the handler's function name for cancellation tracking. (#313)
  • start_async() keeps loading state active during background work — WebSocket responses include async_pending flag when a start_async() callback is running, preventing loading spinners from disappearing prematurely. Async completion responses include event_name so the client clears the correct loading state. Supports named tasks for tracking and cancellation via cancel_async(name). Optional handle_async_result(name, result, error) callback for completion/error handling. (#313, #314)
  • dj-loading.for attribute — Scope any dj-loading.* directive to a specific event name, regardless of DOM position. Allows spinners, disabled buttons, and other loading indicators anywhere in the page to react to a named event. (#314)
  • AsyncWorkMixin included in LiveView base classstart_async() is now available on all LiveViews without explicit mixin import. (#314)
  • Loading state re-scan after DOM patchesscanAndRegister() is called after every bindLiveViewEvents() so dynamically rendered elements (e.g., inside modals) get loading state registration. Stale entries for disconnected elements are cleaned up automatically. (#314)
  • System check djust.T010 for dj-click navigation antipattern — Detects elements using dj-click with navigation-related data attributes (data-view, data-tab, data-page, data-section). This pattern should use dj-patch instead for proper URL updates, browser history support, and bookmarkable views. Warning severity. (#305)
  • System check djust.Q010 for navigation state in event handlers — Heuristic INFO-level check that detects @event_handler methods setting navigation state variables (self.active_view, self.current_tab, etc.) without using patch() or handle_params(). Suggests converting to dj-patch pattern for URL updates and back-button support. Can be suppressed with # noqa: Q010. (#305)
  • Type stubs for Rust extension and LiveView — Added .pyi type stub files for _rust module and LiveView class, enabling IDE autocomplete, mypy/pyright type checking, and catching typos like live_navigate (should be live_patch) at lint time. Includes py.typed marker for PEP 561 compliance and comprehensive documentation in docs/TYPE_STUBS.md.

Deprecated

  • data.type fallback in handleNavigation — The data.action || data.type fallback for pre-#307 clients (added for backwards compatibility in #318) will be removed in the next minor release. Server now sends data.action on all navigation messages. Update any custom client code that sends navigation messages without an action field.

Fixed

  • Silent str() coercion for non-serializable LiveView state — Non-serializable objects stored in self.* during mount() (e.g., service instances, API clients) were silently converted to strings, causing confusing AttributeError on subsequent requests far from the root cause. normalize_django_value() now logs a warning before falling back with the type name, module, and guidance on how to fix. Opt-in strict mode (DJUST_STRICT_SERIALIZATION = True) raises TypeError instead of coercing, recommended for development. New static check djust.V008 (AST-based) detects non-primitive assignments in mount() at development time. (#292)
  • System check S005 incorrectly warns on views with login_required = False — The S005 security check now correctly distinguishes between intentionally public views (login_required = False) and views that haven't addressed authentication at all (login_required = None). Previously, views with login_required = False were incorrectly flagged as missing authentication due to a truthy test. The check now uses explicit is not None comparisons to distinguish intentional public access from unaddressed auth. (#303)
  • |safe filter rendering empty string for nested SafeString values — When mark_safe() HTML was stored in lists of dicts or nested dicts, the |safe filter rendered an empty string instead of preserving the HTML. The _collect_safe_keys() function now recursively scans nested dicts and lists using dotted path notation (e.g., "items.0.content") to track all SafeString locations. Includes circular reference protection to prevent RecursionError on tree/graph structures. (#317)
  • VDOM diff incorrectly matching siblings when {% if %} removes nodes — When {% if %} blocks evaluated to false and removed elements, siblings shifted left, causing diff_indexed_children() to incorrectly match unrelated nodes and generate wrong patches. The template engine now emits <!--dj-if--> placeholder comments when conditions are false (matching Phoenix LiveView's approach), maintaining consistent sibling positions. The VDOM diff detects placeholder-to-content transitions and generates RemoveChild + InsertChild patches instead of Replace patches for semantic consistency. Eliminates DJE-053 fallback to full HTML updates and removes need for style='display:none' workarounds. (#295)
  • Event listener leak causing duplicate WebSocket sends — Single user actions were triggering the same event multiple times (e.g. select_project 5×, mount 3×) because listeners accumulated across VDOM patch/morph cycles without cleanup. Fixed four root causes: (1) initReactCounters now uses a WeakSet guard to skip already-initialized containers; (2) createNodeFromVNode no longer pre-marks elements as bound before bindLiveViewEvents() runs, eliminating a race where newly inserted elements were silently skipped; (3) dj-click handlers now read the attribute at fire-time rather than bind-time, so morphElement attribute updates take effect immediately; (4) three unguarded console.log calls in 12-vdom-patch.js are now wrapped in if (globalThis.djustDebug). The existing WeakMap-based deduplication in bindLiveViewEvents() (introduced in #312) correctly prevents re-binding when called repeatedly. (#315)
  • dj-patch('/') failed to update URL and live_patch routing broken — Removed url.pathname !== '/' guard in bindNavigationDirectives so root-path navigation works. Fixed dict merge order in _flush_navigation so server sends type='navigation' instead of type='live_patch'. Updated handleNavigation to dispatch via data.action with data.action || data.type fallback for backwards compatibility. (#318)
  • 52 unguarded console.log calls in client JS — All console.log calls across 12 files in static/djust/src/ (excluding the intentional debug panel in src/debug/) are now wrapped with if (globalThis.djustDebug). Bare logging in production code leaks internal state to browser consoles and violates the djust.Q003 system check. Files affected: 00-namespace.js, 02-response-handler.js, 03-websocket.js, 04-cache.js, 05-state-bus.js, 06-draft-manager.js, 07-form-data.js, 09-event-binding.js, 10-loading-states.js, 11-event-handler.js, 12-vdom-patch.js, 13-lazy-hydration.js.
  • dj-submit forms sent empty params when created by VDOM patchescreateNodeFromVNode now correctly collects FormData for submit events; replaced data-liveview-*-bound attribute tracking with WeakMap to prevent stale binding flags after DOM replacement (#312)

Security

  • F-strings in logging calls — Converted 9 logger calls to use %-style formatting (logger.error("msg %s", val)) instead of f-strings (logger.error(f"msg {val}")). F-strings defeat lazy evaluation, causing string interpolation before the log level check, potentially exposing sensitive data and wasting CPU. Affected files: mixins/template.py, security/__init__.py, security/error_handling.py, template_tags/__init__.py, template_tags/static.py, template_tags/url.py.

Tests

  • Regression tests for |safe filter with nested dicts — Added comprehensive tests verifying that |safe filter works correctly for HTML content in nested dict/list values, preventing issue #317 from recurring

[0.3.2rc1] - 2026-02-15

Fixed

  • Form data lost on dj-submit — Client-only properties (_targetElement, _optimisticUpdateId, _skipLoading, _djTargetSelector) are now stripped from event params before serialization. Previously, HTMLFormElement references in params corrupted the JSON payload, overwriting form field data with the element's indexed children. (#308)
  • @changedj-change in form adapters — All three framework adapters (Bootstrap 5, Tailwind, Plain) rendered @change="validate_field" instead of dj-change="validate_field", causing real-time field validation to silently fail. (#310)
  • EmailField rendered as type="text"_get_field_type() checked CharField before EmailField (which inherits from CharField), so email fields never got type="email". Reordered the isinstance checks. (#310)

Security

  • XSS in FormMixin.render_field() — Removed render_field(), _render_field_widget(), and _attrs_to_string() from FormMixin. These methods used f-strings with no escaping to build HTML, allowing stored XSS via form field values. Use as_live() / as_live_field() (which delegate to framework adapters with proper escape()) instead. (#310)
  • Textarea content not escaped in adapters_render_input() passed raw textarea values to _build_tag() content without escape(). Added escape(str(value)) for textarea content. (#310)

Changed

  • Framework adapters deduplicated — Created BaseAdapter with all shared rendering logic. Bootstrap5Adapter, TailwindAdapter, and PlainAdapter reduced from ~200 lines each to ~10 lines of class attributes. frameworks.py reduced from ~657 to ~349 lines. (#310)
  • _model_instance support for ModelForm editingFormMixin.mount() now reads field values from _model_instance if set and the form is a ModelForm. _create_form() passes instance= to the form constructor. (#310)

Deprecated

  • LiveViewForm — Emits DeprecationWarning on subclass. Adds no functionality over django.forms.Form. Will be removed in 0.4. (#310)

Removed

  • FormMixin.render_field() — Insecure (XSS via f-strings) and duplicated adapter logic. Use as_live_field() instead. (#310)
  • form_field() function — Dead code, never called. Removed from forms.py and __all__. (#310)

[0.3.1] - 2026-02-14

Changed

  • 3.8x faster rendering for large pages — Optimized get_context_data() by replacing dir(self) iteration (~300 inherited Django View attributes, ~50ms) with targeted __dict__ + MRO walk (<1ms). Added dj-update="ignore" optimization to Rust VDOM diff engine, skipping subtrees the client won't patch (240ms → 17ms). Combined with template-level optimizations, reduces event roundtrip from ~160ms to ~42ms on pages with large static content.

0.3.0 - 2026-02-14

Added

  • dj-confirm attribute — Declarative confirmation dialogs for event handlers. Add dj-confirm="Are you sure?" to any dj-click element to show a browser confirmation dialog before dispatching the event. (#302)

  • CSS Framework Support — Comprehensive Tailwind CSS integration with three-part system: (1) System checks (djust.C010, djust.C011, djust.C012) automatically warn about Tailwind CDN in production, missing compiled CSS, and manual client.js loading. (2) Graceful fallback auto-injects Tailwind CDN in development mode when output.css is missing. (3) CLI helper command python manage.py djust_setup_css tailwind creates input.css with Tailwind v4 syntax, auto-detects template directories, finds Tailwind CLI, and builds CSS with optional --watch and --minify flags. Eliminates duplicate client.js race conditions and guides developers toward production-ready setup.

Fixed

  • Server-side template processing now auto-infers dj-root from dj-view — All template extraction methods (_extract_liveview_content, _extract_liveview_root_with_wrapper, _extract_liveview_template_content, _strip_liveview_root_in_html) now fall back to [dj-view] when [dj-root] is not present, matching the client-side autoStampRootAttributes() behavior introduced in PR #297. This fixes a bug where templates with only dj-view (no explicit dj-root) would fail to render correctly. (#300)
  • Client-side autoMount now correctly reads dj-view attribute — Fixed autoMount() to use getAttribute('dj-view') instead of container.dataset.djView. The dataset API reads data-* attributes, but dj-view is not a data attribute, causing the attribute to be missed. (#300)
  • System check T002 downgraded from WARNING to INFO — Since dj-root is now optional and auto-inferred from dj-view (per PR #297), the T002 check is now informational rather than a warning. The message now clarifies that auto-inference is working correctly. (#300)
  • Duplicate client.js loading race condition — djust now automatically detects and warns (via djust.C012 system check) when base or layout templates manually include <script src="{% static 'djust/client.js' %}">. Since the framework auto-injects client.js, manual loading causes double-initialization and console warnings. The check provides clear guidance to remove manual script tags.
  • Tailwind CDN in production — New djust.C010 system check warns when Tailwind CDN (cdn.tailwindcss.com) is detected in production templates (DEBUG=False). Provides actionable guidance to compile CSS with djust_setup_css command or Tailwind CLI. Prevents slow CDN performance and console warnings in production.

Security

  • Pre-Release Security Audit Process — Comprehensive security infrastructure to prevent vulnerabilities like the mount handler RCE (Issue #298) from reaching production. Includes 259 new security tests (Python + Rust) covering parameter injection, file upload attacks, URL injection, and XSS prevention across all contexts. Three GitHub workflows provide automated security scanning (bandit, safety, cargo-audit, npm audit, CodeQL), hot spot detection (auto-labels PRs touching security-sensitive code), and CI security test job requiring 85% coverage for security-sensitive modules. New pre-release security audit template with 7-phase checklist ensures comprehensive review before each release. Documentation updates establish mandatory security gates and review requirements for changes to hot spot files.

Dependencies

  • Bump happy-dom from 20.5.3 to 20.6.1 (#289)
  • Bump tempfile from 3.24.0 to 3.25.0 (#288)

[0.3.0rc5] - 2026-02-11

Added

  • Automatic change tracking — Phoenix-style render optimization. The framework automatically detects which context values changed between renders and only sends those to Rust's update_state(). Replaces the manual static_assigns API. Two-layer detection: snapshot comparison for instance attributes, id() reference comparison for computed values (e.g., @lru_cache results). Immutable types (str, int, float, bool, None, bytes, tuple, frozenset) skip deepcopy in snapshots.

Removed

  • static_assigns class attribute — Replaced by automatic change tracking. The framework now detects unchanged values automatically — no manual annotation needed.

[0.3.0rc4] - 2026-02-11

Added

  • All 57 Django template filters — The Rust template engine now supports the complete set of Django built-in filters. Added 24 filters across two batches: default_if_none, wordcount, wordwrap, striptags, addslashes, ljust, rjust, center, make_list, json_script, force_escape, escapejs, linenumbers, get_digit, iriencode, urlize, urlizetrunc, truncatechars_html, truncatewords_html, safeseq, escapeseq, unordered_list, phone2numeric, pprint. (#246, #254)
  • Authentication & Authorization — Opinionated, framework-enforced auth for LiveViews. View-level login_required and permission_required class attributes (plus LoginRequiredMixin/PermissionRequiredMixin for Django-familiar patterns). Custom auth logic via check_permissions() hook. Handler-level @permission_required() decorator for protecting individual event handlers. Auth checks run server-side before mount() and before handler dispatch — no client-side bypass possible. Integrates with djust_audit command (shows auth posture per view) and Django system checks (djust.S005 warns on unprotected views with exposed state).
  • Navigation & URL Statelive_patch() updates URL query params without remount, live_redirect() navigates to a different view over the same WebSocket. Includes handle_params() callback, live_session() URL routing helper, and client-side dj-patch/dj-navigate directives with popstate handling. (#236)
  • Presence Tracking — Real-time user presence with PresenceMixin and PresenceManager. Pluggable backends (in-memory and Redis). Includes LiveCursorMixin and CursorTracker for collaborative live cursor features. (#236)
  • StreamingStreamingMixin for real-time partial DOM updates (e.g., LLM token-by-token streaming). Provides stream_to(), stream_insert(), stream_text(), stream_error(), stream_start()/stream_done(), and push_state(). Batched at ~60fps to prevent flooding. (#236)
  • File UploadsUploadMixin with binary WebSocket frame protocol for chunked file uploads. Includes progress tracking, magic bytes validation, file size/extension/MIME checking, and client-side dj-upload/dj-upload-drop directives. (#236)
  • JS Hooksdj-hook attribute for client-side JavaScript lifecycle hooks (mounted, updated, destroyed, disconnected, reconnected). (#236)
  • Model Bindingdj-model two-way data binding with .lazy and .debounce-N modifiers. Server-side ModelBindingMixin with security field blocklist and type coercion. (#236)
  • Client Directivesdj-confirm confirmation dialogs, dj-target scoped updates, embedded view routing in event handlers. (#236)
  • Server-Push API — Background tasks (Celery, management commands, cron jobs) can now push state updates to connected LiveView clients via push_to_view(). Includes per-view channel groups (auto-joined on mount), a sync/async public API (push_to_view / apush_to_view), and periodic handle_tick() for self-updating views. (#230)
  • Progressive Web App (PWA) Support — Complete offline-first PWA implementation with service worker integration, IndexedDB/LocalStorage abstraction, optimistic UI updates, and offline-aware template directives. Includes comprehensive template tags ({% djust_pwa_head %}, {% djust_pwa_manifest %}), PWA mixins (PWAMixin, OfflineMixin, SyncMixin), and automatic synchronization when online. (#235)
  • Multi-Tenant SaaS Support — Production-ready multi-tenant architecture with flexible tenant resolution strategies (subdomain, path, header, session, custom, chained), automatic data isolation, tenant-aware state backends, and comprehensive template context injection. Includes TenantMixin and TenantScopedMixin for views. (#235)
  • dj-poll attribute — Declarative polling for LiveView elements. Add dj-poll="handler_name" to any element to trigger the handler at regular intervals. Configurable via dj-poll-interval (default: 5000ms). Automatically pauses when the page is hidden and resumes on visibility change. (#269)
  • DjustMiddlewareStack — New ASGI middleware for apps that don't use django.contrib.auth. Wraps WebSocket routes with session middleware only (no auth required). Updated C005 system check to recognize both AuthMiddlewareStack and DjustMiddlewareStack. (#265)
  • System check C006 — Warns when daphne is in INSTALLED_APPS but whitenoise middleware is missing. (#259)
  • startproject / startapp / new CLI commandspython -m djust new myapp creates a full project with optional features (--with-auth, --with-db, --with-presence, --with-streaming, --from-schema). Legacy startproject and startapp commands also available. (#266)
  • djust mcp install CLI command — Automates MCP server setup for Claude Code, Cursor, and Windsurf. Tries claude mcp add first (canonical for Claude Code), falls back to writing .mcp.json directly. Merges with existing config, backs up malformed files, idempotent.
  • Simplified root elementdj-view is now the only required attribute on LiveView container elements. The client auto-stamps dj-root and dj-liveview-root at init time. Old three-attribute format still works. (#258)
  • Model .pk in templates{{ model.pk }} now works in Rust-rendered templates. Model serialization includes a pk key with the native primary key value. (#262)
  • Better Error Messages — Improved error messages for common LiveView event handler mistakes (missing @event_handler, wrong method signature). (#248)
  • LiveViewSmokeTest mixin — Automated smoke and fuzz testing for LiveView classes. (#251)
  • MCP serverpython manage.py djust_mcp starts a Model Context Protocol server for AI assistant integration. Provides framework introspection, system checks, scaffolding, and validation tools. Used by djust mcp install to configure Claude Code, Cursor, and Windsurf.
  • djust_audit management command — Security audit showing auth posture, exposed state, and handler signatures per view.
  • djust_check management command — Django system checks for project validation. Gains --fix flag for safe auto-fixes and --format json for enhanced output with fix hints.
  • djust_schema management command — Extract and generate Django models from JSON schema files.
  • djust_ai_context management command — Generate AI-focused context files for LLM integrations.
  • AI documentationdocs/ai/ with focused guides for events, forms, JIT, lifecycle, security, and templates. docs/llms.txt and docs/llms-full.txt for LLM context.
  • Auto-build client.js from src/ modules — Pre-commit hook runs build-client.sh when src/ files change. (#211)
  • Keyed-mutation fuzz test generator — New proptest generator produces tree B by mutating tree A, exercising keyed diff paths more effectively. Proptest cases bumped from 500 to 1000. (#216, #217)

Changed

  • BREAKING: data-dj-* prefix stripping — Client-side extractTypedParams() now strips the dj_ prefix from data-dj-* attributes. data-dj-preset="dark" sends {preset: "dark"} instead of {dj_preset: "dark"}. Update handler parameter names accordingly: dj_foofoo.
  • State Backends — Enhanced with tenant-aware isolation support (TenantAwareRedisBackend, TenantAwareMemoryBackend).

Performance

  • Batched sync_to_async calls — Event handler processing now uses 2 thread hops instead of 4, saving ~1-4ms per event. (#277)
  • Eliminated JSON encode/decode roundtrip — Direct normalize_django_value() Python-to-Python type normalization replaces 17 json.loads(json.dumps(...)) patterns. Saves 2-5ms per event for views with database objects. (#279)
  • Cached template variable extraction — Rust extract_template_variables() results cached by content hash (SHA-256). Size-capped at 256 entries with automatic eviction. (#280)
  • Cached context processor resolutionresolve_context_processors() results cached per settings configuration. Invalidated on setting_changed signal. (#281)
  • JIT short-circuit for non-DB views — Views without QuerySets or Models in context skip the entire JIT serialization pipeline. Saves ~0.5ms per event for simple views. (#278)
  • Slimmer debug payload — Event responses send only state variables; handler metadata moved to initial mount as static data. ~68% smaller debug payloads (~25KB → ~8KB per event).

Fixed

  • Inline args on form eventsdj-change, dj-input, dj-blur, dj-focus now parse inline arguments (e.g., dj-change="toggle(3)") before sending to server. Also fixed state change detection to use deep copy comparison, catching in-place mutations.
  • Error overlay on intentional disconnect — Suppress "WebSocket Connection Failed" overlay during TurboNav navigation via _intentionalDisconnect flag.
  • VDOM patch failure recovery — When VDOM patches fail, the client requests recovery HTML on demand instead of reloading the page. Uses DOM morphing to preserve event listeners and form state. (#259)
  • HTTP Fallback Protocolpost() now accepts the HTTP fallback format where the event name is in the X-Djust-Event header and params are flat in the body JSON. (#255)
  • Debug panel HTTP-only mode — POST responses include _debug payload when DEBUG=True, enabling the debug panel in HTTP-only mode. (#267)
  • Silent LiveView config failures — Client JS now shows helpful console.error when no LiveView containers are found. Added system check V005 for modules not in LIVEVIEW_ALLOWED_MODULES. (#257)
  • HTTP-only mode session state on GETget() now saves view state to the session immediately when use_websocket: False. (#264)
  • use_websocket: False client-side enforcement — Setting now actually prevents WebSocket connections. (#260)
  • DOM morphing preserves event listenershtml_update now uses morphdom-style DOM diffing instead of innerHTML. (#236)
  • Textarea newlines preserved — Template whitespace stripping no longer collapses newlines inside <textarea> elements. (#236)
  • PresenceMixin crash without authtrack_presence() now checks for request.user before accessing it. (#236)
  • _skip_render support in server_pushserver_push() now checks _skip_render, preventing phantom renders and VDOM version mismatches. (#236)
  • Client-side SetText mis-targets after keyed MoveChild — MoveChild patches now include child_d for data-dj-id resolution. (#225)
  • VDOM diff/patch round-trip on keyed child reorder — Patches now processed level-by-level (shallowest parent first). (#212)
  • apply_patches djust_id-based resolution — Resolves parent nodes by djust_id instead of path-based traversal. (#216)
  • Diff engine keyed+unkeyed interleaving — Emits MoveChild patches for unkeyed element children in keyed contexts. (#219)
  • Text node targeting after keyed movesSetText patches carry djust_id when available; sync_ids propagates IDs to text nodes. (#221)
  • Tag registry test pollutionclear_tag_handlers() now restores built-in handlers in teardown. (#261)

Security

  • HTTP POST handler dispatch gatingpost() now enforces the same security model as the WebSocket path: only @event_handler-decorated methods can be invoked. Validates event names with is_safe_event_name() to block dunders and private methods.
  • Auto-escaping in Rust template engineSafeString values propagated to Rust for proper auto-escaping.
  • HTML-escaped urlize and unordered_list filters — Both filters now escape their output to prevent XSS. (#254)
  • Template tag XSS prevention — All PWA template tags now use format_html() and escape() instead of mark_safe() with f-string interpolation.
  • Sync endpoint hardening — Removed @csrf_exempt from sync_endpoint_view. Added authentication requirement, payload validation, and safe field extraction.
  • Silent exception elimination — All except: pass patterns replaced with appropriate logging calls.
  • Production JS hardened — All console.log calls guarded behind djustDebug flag.

Removed

  • _allowed_events class attribute — The backwards-compatibility escape hatch that allowed undecorated methods to be called via WebSocket or HTTP POST has been removed. All event handlers must now use the @event_handler decorator.

0.2.2 - 2026-02-01

Fixed

  • Stale Closure Args on VDOM-Patched Elements — After deleting a todo, the remaining button's click handler sent the wrong _args (stale closure from bind time) because SetAttribute patches updated the dj-click DOM attribute but not the listener closure. Event listeners now re-parse dj-* attributes from the DOM at event time. Also sets dj-* as DOM attributes in createNodeFromVNode and marks elements as bound to prevent duplicate listeners. (#205)
  • VDOM: Non-breaking Space Text Nodes Stripped — Rust parser stripped &nbsp;-only text nodes (used in syntax highlighting) because char::is_whitespace() includes U+00A0. Now preserves \u00A0 text nodes in parser, to_html(), and client-side path traversal. Also adds sync_ids() to prevent ID drift between server VDOM and client DOM after diffing, and 4-phase patch ordering matching Rust's apply_patches(). (#199)
  • CSRF Token Lookup on Formless Pages — Pages without a <form> element failed to send CSRF tokens with WebSocket events. Token lookup now falls back to the csrftoken cookie. (#210)
  • Codegen Crash on Numeric Index Paths — Template expressions like {{ posts.0.url }} produced paths starting with a numeric index (0.url), generating invalid Python (obj.0). Codegen now skips numeric-leading paths since list items are serialized individually.
  • JIT Serialization Pipeline — Fixed multiple issues in JIT auto-serialization: (#140)
    • M2M .all() traversal now generates correct iteration code in codegen serializers
    • @property attributes are now serialized via Rust→Python codegen fallback when Rust can't access them
    • list[Model] context values (not just QuerySets) now receive full JIT optimization with select_related/prefetch_related
    • Nested dicts containing Model/QuerySet values are now deep-serialized recursively
    • _djust_annotations model class attribute for declaring computed annotations (e.g., Count) applied during query optimization
    • {% include %} templates are now inlined for variable extraction, so included template variables get JIT optimization
    • Rust template parser now correctly prefixes loop variable paths (e.g., item.field inside {% for item in items %})
  • {% include %} After Cache Restoretemplate_dirs was not included in msgpack serialization of RustLiveView. After a cache hit, the restored view had empty search paths, causing {% include %} tags to fail with "Template not found". Now calls set_template_dirs() on both WebSocket and HTTP cache-hit paths.
  • VDOM Replace Sibling Grouping — Fixed data-djust-replace inserting children into wrong parent when the replace container has siblings. groupPatchesByParent() now uses the full path for child-operation patches, and groupConsecutiveInserts() checks parent identity before batching. (#144)
  • VDOM Replace Child Removal — Fixed data-djust-replace not removing old children before inserting new ones, causing duplicate content on re-render. (#142, #143)
  • Context Processor Precedence — View context now takes precedence over context processors. Previously, context processors could overwrite view-defined variables (e.g., Django's messages processor overwriting a view's messages variable).
  • VDOM Keyed Diff Insert Ordering — Fixed apply_patches for keyed diff insert ordering where items were inserted in the wrong position. (#154)
  • VDOM MoveChild Resolution — Fixed MoveChild in apply_patch by resolving children via djust_id instead of index. (#150)
  • Debug Toolbar: Received WebSocket Messages Not Captured — Network tab now captures both sent and received WebSocket messages by intercepting the onmessage property setter (not just addEventListener). (#188)
  • Debug Toolbar: Events Tab Always Empty — Events tab now populates by extracting event data from sent WebSocket messages and matching responses, replacing the broken window.liveView hook. (#188)
  • Debug Panel: Handler Discovery, Auto-loading, Tab Crashes — Handler discovery now finds all public methods; debug-panel.js auto-loads; handler dict normalized to array; retroactive WebSocket hooking for late-loading panels. (#191, #197)

Added

  • Debug Panel: Live Debug Payload — When DEBUG=True, WebSocket event responses now include a _debug field with updated variables, handlers, patches, and performance metrics. (#191)
  • Debug Toolbar: Event Filtering — Events tab filter controls to search by event/handler name and filter by status. (#180)
  • Debug Toolbar: Event Replay — Replay button (⟳) that re-sends events through the WebSocket with original params. (#181)
  • Debug Toolbar: Scoped State Persistence — Panel UI state scoped per view class via localStorage. (#182)
  • Debug Toolbar: Network Message Inspection — Directional color coding and copy-to-clipboard for expanded payloads. (#183)
  • Debug Toolbar: Test Harness — Integration tests against the actual DjustDebugPanel class. (#185)
  • VDOM Proptest/Fuzzing — Property-based testing for the VDOM diff algorithm with proptest. (#153)
  • Duplicate Key Detection — VDOM keyed diff now warns on duplicate keys. (#149)
  • Branding Assets — Official logo variants (dark, light, icon, wordmark, transparent). (#208, #213)

Deprecated

  • @event decorator alias — The @event shorthand is deprecated in favor of @event_handler. @event will be removed in v0.3.0. A deprecation warning is emitted at import time. (#141)

Changed

  • Internal: LiveView Mixin Extraction — Refactored monolithic live_view.py into focused mixins: RequestMixin, ContextMixin, JITMixin, TemplateMixin, RustBridgeMixin, ComponentMixin, LifecycleMixin. No public API changes. (#130)
  • Internal: Module Splits — Split client.js into source modules with concat build, extracted websocket_utils.py, session_utils.py, serialization.py, split state_backend.py into state_backends package, split template_backend.py into template package. (#124, #125, #126, #128, #129)
  • Dependencies — Upgraded uuid 1.19→1.20, thiserror 1→2, bincode 1→2, happy-dom 20.3.7→20.4.0, actions/setup-python 5→6, actions/upload-artifact 4→6, actions/checkout 4→6, softprops/action-gh-release 1→2

0.2.1 - 2026-01-29

Security

  • WebSocket Event Security Hardening - Three-layer defense for WebSocket event dispatch: (#104)
    • Event name guard — regex pattern filter (^[a-z][a-z0-9_]*$) blocks private methods, dunders, and malformed names before getattr()
    • @event_handler decorator allowlist — only methods decorated with @event_handler (or listed in _allowed_events) are callable via WebSocket. Configurable via event_security setting ("strict" default, "warn", "open")
    • Server-side rate limiting — per-connection token bucket algorithm with configurable rate/burst. Per-handler @rate_limit decorator for expensive operations. Automatic disconnect after repeated violations (close code 4429)
    • Per-IP connection limit — process-level IPConnectionTracker enforces a maximum number of concurrent WebSocket connections per IP (default: 10) and a reconnection cooldown after rate-limit disconnects (default: 5 seconds). Configurable via max_connections_per_ip and reconnect_cooldown in rate_limit settings. Supports X-Forwarded-For header for proxied deployments. (#108, #121)
    • Message size limit — 64KB default (max_message_size setting)

Documentation

  • Added migration guide for @event_handler decorator requirement and strict mode upgrade path (#105, #122)
  • Added @event_handler decorator to all example demo view handler methods

Added

  • is_event_handler(func) — check if a function is decorated with @event_handler
  • @rate_limit(rate, burst) — per-handler server-side rate limiting decorator
  • _allowed_events class attribute — escape hatch for bulk allowlisting without decorating each method
  • LIVEVIEW_CONFIG settings: event_security, rate_limit (including max_connections_per_ip, reconnect_cooldown), max_message_size

0.2.0 - 2026-01-28

Added

  • Template and/or/in Operators - {% if %} conditions now support and, or, and in boolean/membership operators with correct precedence and chaining. (#103)

Fixed

  • Pre-rendered DOM Whitespace Preservation - WebSocket mount no longer replaces innerHTML when content was pre-rendered via HTTP GET. Instead, data-dj-id attributes are stamped onto existing DOM elements, preserving whitespace in code blocks and syntax-highlighted content. (#99)

  • VDOM Keyed Diffing - Unkeyed children in keyed diffing contexts are now matched by relative position among unkeyed siblings, eliminating spurious insert+remove patch pairs when keyed children reorder. (#95, #97)

  • Event Handler Attributes Preserved - dj-* event handler attributes are no longer removed during VDOM patching. (#100)

  • Model List Serialization - Lists of Django Model instances are now properly serialized on GET requests. (#103)

  • Mount URL Path - WebSocket mount requests now use the actual page URL instead of a hardcoded path. (#95)

Changed

  • Dependencies - Upgraded html5ever 0.27→0.36, markup5ever_rcdom 0.3→0.36, vitest 2.x→4.x, actions/download-artifact 4→7. (#101, #102, #43)

Developer Experience

  • VDOM Debug Tracing - debug_vdom Django config is now bridged to Rust VDOM tracing. Mixed keyed/unkeyed children emit developer warnings. (#97)

0.2.0a2 - 2026-01-27

Changed

  • Internal: DRY Refactoring - Reduced ~275 lines of duplicate code across the codebase through helper function extraction. These are internal improvements that don't affect the public API. (#93, #94)
    • getComponentId() - DOM traversal for component ID lookup (client.js)
    • buildFormEventParams() - Form event parameter building (client.js)
    • send_error() - WebSocket error response helper (websocket.py)
    • _send_update() - WebSocket patch/HTML response helper (websocket.py)
    • _create_rust_instance() - Rust component instantiation (base.py)
    • _render_template_with_fallback() - Template rendering with Rust→Django fallback (base.py)
    • _make_metadata_decorator() - Decorator factory for metadata-only decorators (decorators.py)

0.2.0a1 - 2026-01-26

Changed

  • BREAKING: Event Binding Syntax - Standardized all event bindings to use dj- prefix instead of @ prefix. This affects all event attributes: @clickdj-click, @inputdj-input, @changedj-change, @submitdj-submit, @blurdj-blur, @focusdj-focus, @keydowndj-keydown, @keyupdj-keyup, @loading.*dj-loading.*. Benefits: namespaced attributes, no conflicts with Vue/Alpine, no CSS selector escaping required. (#68)

  • BREAKING: Component Consolidation - Removed legacy python/djust/component.py. Use djust.Component which now imports from components/base.py. (#89)

  • BREAKING: Method Rename - LiveComponent.get_context()get_context_data() for Django consistency. (#89)

  • BREAKING: Decorator Attributes Removed - Deprecated decorator attributes removed: _is_event_handler, _event_name, _debounce_seconds, _debounce_ms, _throttle_seconds, _throttle_ms. Use _djust_decorators dict instead. (#89)

  • BREAKING: Data Attributes Renamed - Standardized data attribute naming for consistency:

    • dj-liveview-rootdj-root
    • data-live-viewdj-view
    • data-live-lazydj-lazy
    • data-djdata-dj-id (#89)
  • BREAKING: WebSocket Message Types - Renamed message types for consistency:

    • connectedconnect
    • mountedmount
    • hotreload.messagehotreload (#89)

Added

  • LiveComponent Methods - Added missing methods to LiveComponent: _set_parent_callback(), send_parent(), unmount(). (#89)

  • Inline Template Support - LiveComponent now supports inline template attribute for template strings, in addition to template_name for file-based templates. (#89)

  • Form Components Export - ForeignKeySelect and ManyToManySelect are now exported from djust.components. (#89)

Fixed

  • {% elif %} Tag Support: Template parser now correctly handles {% elif %} conditionals. Previously, elif branches fell through to the unknown tag handler and rendered all branches instead of just the matching one. (#80)

  • Template Include Fallback - Component render() methods now fall back to Django templates when Rust template engine fails (e.g., for {% include %} tags). (#89)

0.1.8 - 2026-01-25

Fixed

  • Nested Block Inheritance: Fixed template inheritance for nested blocks. When a child template overrides a block that is nested inside another block in the parent (e.g., content inside body), the override is now correctly applied. (#71)

0.1.7 - 2026-01-25

Added

  • Tag Handler Registry: Extensible system for custom Django template tags in Rust. Register Python callbacks for tags like {% url %} and {% static %} with ~100-500ns overhead per call. Built-in tags (if, for, block) remain zero-overhead native Rust. Includes ADR documenting architecture decisions. (#65)
  • Comparison Operators: Template conditions now support >, <, >=, <= operators in addition to == and !=. (#65)
  • Enhanced {% include %} Tag: Full support for with clause (pass variables) and only keyword (isolate context). (#65)
  • Performance Testing Infrastructure: Comprehensive benchmarking with Criterion (Rust) and pytest-benchmark (Python). New Makefile commands: make benchmark, make benchmark-quick, make benchmark-e2e. Enables tracking performance across releases and detecting regressions. (#69)
  • Inline Handler Arguments: Event handlers now support function-call syntax with arguments directly in the template attribute. Use dj-click="handler('arg')" instead of dj-click="handler" data-value="arg". Supports strings, numbers, booleans, null, and multiple arguments. (#67)

Fixed

  • Async Event Handlers: WebSocket consumer now properly supports async def event handlers. Previously only synchronous handlers worked correctly. (#63)

Performance

  • Dashboard render: ~37µs (27,000 renders/sec)
  • Tag handler overhead: ~100-500ns per call
  • Template variable substitution: ~970ns
  • 50-row data table: ~188µs

0.1.6 - 2026-01-24

Added

  • {% url %} Tag Support: Django's {% url %} template tag is now fully supported with automatic Python-side URL resolution. Supports named URLs, namespaced URLs, and positional/keyword arguments. (#55)
  • {% include %} Tag Support: Fixed template include functionality by passing template directories to the Rust engine. Included templates are now correctly resolved from configured template paths. (#55)
  • urlencode Filter: Added the urlencode filter for URL-safe encoding of strings. Supports encoding all characters or preserving safe characters. (#55)
  • Comparison Operators in {% if %} Tags: Added support for >, <, >=, <= comparison operators in conditional expressions. (#55)
  • Auto-serialization for Django Types: Context variables with Django types (datetime, date, time, Decimal, UUID, FieldFile) are now automatically serialized for Rust rendering. No manual JSON conversion required. (#55)
  • Lazy Hydration: LiveView elements can now defer WebSocket connections until they enter the viewport or receive user interaction. Use dj-lazy attribute with modes: viewport (default), click, hover, or idle. Reduces memory usage by 20-40% per page for below-fold content. (#54)
  • TurboNav Integration: LiveView now works seamlessly with Turbo-style client-side navigation. WebSocket connections are properly disconnected on navigation and reinitialized when returning to a page. (#54)

Changed

  • AST Optimization: Template parser now merges adjacent Text nodes during AST optimization, reducing allocations and improving render time by 5-15%. Comment nodes are also removed during optimization as they produce no output. (#54)

Fixed

  • Nested Block Inheritance: Fixed template inheritance for nested blocks (e.g., docs_content inside content). Block overrides are now recursively applied to merged content, ensuring deeply nested blocks are correctly resolved. (#57)
  • Form Validation First-Click Issue: Added parse_html_continue() function to maintain ID counter continuity across parsing operations. Prevents ID collisions when inserting dynamically generated elements (like validation error messages) that caused first-click validation issues. (#54)
  • Whitespace Preservation: Whitespace is now preserved inside <pre>, <code>, <textarea>, <script>, and <style> elements during both Rust parsing and client-side DOM patching. (#54)

Security

  • pyo3 Upgrade: Upgraded pyo3 from 0.22 to 0.24 to address RUSTSEC-2025-0020 (buffer overflow vulnerability in PyString::from_object). (#55)

0.1.5 - 2026-01-23

Added

  • Context Processor Support: LiveView now automatically applies Django context processors configured in DjustTemplateBackend. Variables like GOOGLE_ANALYTICS_ID, user, messages, etc. are now available in LiveView templates without manual passing. (#26)

Fixed

  • VDOM Cache Key Path Awareness: Cache keys now include URL path and query string hash, preventing render corruption when navigating between views with different template structures (e.g., /emails/ vs /emails/?sender=1). (#24)

0.1.4 - 2026-01-22

Added

  • Initial public release
  • LiveView reactive server-side rendering
  • Rust-powered VDOM engine (10-100x faster than Django templates)
  • WebSocket support for real-time updates
  • 40+ UI components (Bootstrap 5 and Tailwind CSS)
  • State management decorators (@state, @computed, @debounce, @optimistic)
  • Form handling with real-time validation
  • Testing utilities (LiveViewTestClient, snapshot testing)

0.1.3 - 2026-01-22

Fixed

  • Bug fixes and stability improvements