djust 1.3.0rc7

Pre-release Security Released
Install
pip install djust==1.3.0rc7

Before you upgrade, read the upgrade guide.

The seventh 1.3 release candidate. It folds in the open-issue drain (#3300) and includes security fixes; upgrading from 1.3.0rc6 is recommended.

Added

  • Block-tag forms for the container theme components (#2894, #3279). {% theme_card_block %}…{% end_theme_card_block %} and {% theme_alert_block %}…{% end_theme_alert_block %} take a body of template tags, so a card can hold a button, a loop or an {% if %} without pre-rendering HTML in Python and passing it through a |safe slot. The convention is the inline tag's name plus _block, closing with {% end_<name>_block %}, and it covers every container whose body was a string: theme_modal_block, theme_dropdown_block (the menu), theme_tooltip_block (the wrapped content) and theme_nav_group_block (the items) as well. The inline tags are unchanged. A block form takes its inline tag's arguments except the one the block fills, and passing that argument (or its slot_* spelling) too is a TemplateSyntaxError. The body is rendered by Django's engine in the caller's context, so autoescaping applies to {{ variable }} inside it. Each block form calls its inline function, so the markup and the per-theme card.html overrides are the inline form's. They work in LiveView (Rust-rendered) templates through the {% load %} bridge. djust_theme init --with-examples now writes the block form. {% theme_card %}…{% end_theme_card %}, the spelling #2894 once documented, is still not valid; end_theme_card (and the other five) now raises an error pointing to the block form. theme_alert_block refuses positional arguments (the first positional of theme_alert is the message the block fills), and the block forms do not support as <variable>. Regression cases in python/djust/tests/test_theming_block_tags_2894.py.
  • A reused sticky {% live_render ... sticky=True %} child can now take changed tag kwargs through a new _on_sticky_update(self, changed) hook (#2919). On the parent render that sees different values, djust calls it on the live child, before the child renders, with only the kwargs that are new or differ, mapped to their new values. The child is not remounted and its state is not reset. A child without the override behaves as before (new values ignored, one warning, which now names the hook). The name is private so no client event can reach it. A sticky preserved across live_redirect is not re-rendered and does not run the hook; exposure_policy = "explicit" children are remounted when inputs change. See the sticky LiveViews guide.
  • Eager, lazy and batched LiveViews on one WebSocket are independently live (#3252). The socket used to hold one view_instance: hydrating a dj-lazy view replaced the page view (its next click was "Event rejected"), a push aimed at one view ran on the last mounted one, and only the last view of a mount_batch was live. Each view a mount frame names with a target_id (the lazy hydration of the stock client, which names the container), and each mount_batch entry, is now a view of its own beside the page view: a full root with its own runtime (mount, authorization, state restore, explicit-exposure turns), tick task, wire version, presence, group memberships and teardown. A page mount (no target_id) and live_redirect still replace every view. The rest of the verbs are routed by the frame's target_id: event, url_change, request_html, uploads, presence heartbeats, and the new unmount (tears one view down, the others stay live). Server pushes, presence events and db_notify reach the views that joined the group they were sent to (push_to_view and presence broadcasts now carry the group), and every frame a view sends is stamped with its target_id (the page view's frames are unchanged). A view refused at mount (login, permission, object permission) fails alone and no longer closes the socket, an authorization refusal at event time ends that view only, and LIVEVIEW_CONFIG["max_views_per_connection"] (default 64) bounds how many a socket hosts. The client (13b-view-slots.js) addresses events and recovery requests to the view they come from, applies a slot's frames to its container against its own VDOM version, and mounts every view again after a reconnect. Everything a lazy view's elements send is addressed to it: events from dj-input, dj-model, dj-poll, forms and dj-hook pushEvent, and the upload_register / upload_resume frames of its file inputs (the binary chunks reach the view that registered the upload's ref); a view whose container leaves the page is unmounted on the server; and two views that share a channel-layer group (one class, one db_notify channel, one presence key) each keep it until the last leaves. Over the HTTP fallback and SSE, which host one view, an event from a lazily hydrated view is refused instead of being run on the page view. Behavior change for code that sends to the channel layer directly: a raw group_send of a server_push, presence_event or db_notify message that names no group is dropped when more than one view is mounted on the socket (it cannot be told apart per view); push_to_view, PresenceManager and the db_notify listener add the group themselves, and a socket with one view is unchanged.
  • The theming layouts can host a LiveView base template (#3278). The base layout gains {% block body_attrs %} (attributes on <body>) and {% block client_config %} (in <head>, for {% djust_client_config %}), and each layout that wraps its content in <main> gains a block around that whole element (sidebar_topbar_main, sidebar_main, topbar_main, centered_main) so a page can supply its own <main dj-root> without nesting two. With no block overridden every layout renders byte-for-byte what it did before. Regression cases in python/djust/tests/test_theming_layouts_blocks_3278.py.
  • Component tags carry djust bindings and attributes (#3279, partial). {% theme_button %} forwards any extra keyword to the element (dj_click, dj_value_id, name, value, disabled, data_*, aria_*; underscores become hyphens, each value is escaped) and href= / element= render an <a class="btn ..."> for link-styled buttons. {% theme_select %} takes the same pass-through. {% theme_input %}, {% theme_select %} and {% theme_textarea %} accept id= so repeated fields get unique ids (default stays name). The shared pass-through now validates attribute names and refuses on* event-handler attributes and javascript: / vbscript: / data: URLs with ValueError; upgrade note: {% theme_input %} and {% theme_progress %} used to emit an onfocus= or onchange= keyword silently, they now raise (use a dj-* binding). On a link-styled button type= raises and disabled=True renders aria-disabled="true" tabindex="-1" without href; djust_theme check-compat warns about a project button.html that ignores extra_attrs, href and tag. Not included: block-tag forms of theme_card / theme_alert, which stay with #2894. Regression cases in python/djust/tests/test_theming_component_attrs_3279_3288.py.
  • {% theme_alert %} gains an info variant and a role parameter (#3280). variant="info" renders alert-info (the info token at 10% tint, in components.css and the Tailwind output) and role= takes alert, status or log. The default is alert for destructive and warning and status for the rest, so upgrade note: a default, success or info alert now renders role="status" (polite) instead of role="alert"; pass role="alert" to keep the old behaviour. info text on its tint joins the contrast matrix (CONTRAST_PAIRS, ThemeTokens.info_tint); the legal dark info colour was lightened to pass, and the other presets' 83 failures are documented rows citing #2885. Regression cases in python/djust/tests/test_theming_alert_info_role_3280.py.
  • New system check djust.T024 warns when a template a LiveView or LiveComponent renders (a template file, its parents and includes, or an inline template string) reads is_staff, is_superuser or password through a user variable or a {% with %} alias of one ({% if request.user.is_staff %}). The serializer never ships those fields, so the expression was silently always false. The BEST_PRACTICES guide documents the derived-boolean pattern (self.can_manage = request.user.is_staff, or a context processor) (#3282).
  • {% theme_head %} puts a CSP nonce on its inline <script> and <style> blocks (#3284). The nonce is request.csp_nonce when django-csp sets one, or {% theme_head nonce=request.csp_nonce %}; nonce="" disables it and an explicit value wins over the request. {% theme_css %}, {% theme_framework_overrides %}, the {{ theme_head }} context variable and ThemeMixin.theme_head carry it too. With no nonce the output is unchanged, and the script is otherwise byte-identical. build_theme_head_context() gains a nonce argument and returns a nonce_attr key. Known limit: the deferred-stylesheet <link> still uses an inline onload, which a nonce does not authorise. Regression cases in python/djust/tests/test_theming_head_nonce_3284.py.
  • {% theme_preset_selector presets="legal,medical,default" %} and LIVEVIEW_CONFIG["theme"]["selectable_presets"] limit the preset list (#3285). Both take a comma-separated string or a list, in display order; the tag argument wins over the setting (presets="" means no limit), and an unknown name in the tag raises ValueError. The setting is read on every request, so a bad value never raises there: unknown names are dropped with one logged warning, all presets are listed when none is usable, and the new djust_theming.W003 system check reports it. The setting also limits the presets in theme_panel and theme_switcher and the theme_presets context variable. Regression cases in python/djust/tests/test_theming_preset_allow_list_3285.py.
  • manage.py check has a new warning, djust.T025, for dj-view / dj-root on <html>, <head> or <body>. The HTTP render of such a page is complete, but the WebSocket mount keeps only the first element inside <body>, so the live page silently lost content. The check skips {% verbatim %} regions, HTML comments and <script> bodies, and one match can be silenced with {# noqa: T025 -- <reason> #}. djust.T002 no longer says a root on those elements "still connects": it stays silent for them and limits its reassurance to a root inside <body>. The mount-time root detection itself is unchanged (#3302).
  • Auth refusals carry a stable code on every transport, and reach the page without parsing message text (#3319). The refusal frames that lacked one (a @permission_required handler, a mount refused with PermissionDenied) and the HTTP fallback's 403 bodies now carry "code": "permission_denied" next to the unchanged error text, as the revoked-authority and object-level frames already did. The SSE reauth_on_event frame sent the numeric close code 4403 as its code; it now sends "permission_denied". On the client, djust:error gains detail.code (WebSocket, SSE and HTTP fallback) and djust:auth-refused gains detail.error_code, the code of the refusal frame that preceded the 4401/4403 close (null when there was none); detail.code stays the numeric close code. Documented in the authentication guide ("Refusal codes"). Regression cases in python/djust/tests/test_auth_refusal_codes_3319.py and tests/js/auth-refusal-code-3319.test.js.
  • ThemeTokens.destructive_text and the --destructive-text CSS variable (#3320). A read-only token derived per preset and mode: the preset's destructive hue and saturation with only the lightness moved, to the nearest value that reaches 4.5:1 on the page, on a card and on the 10% and 15% destructive washes behind alerts and toasts. A destructive that already reads is kept exactly. It is emitted wherever the theme variables are (:root, html[data-theme], the system-preference block), as text-destructive-text in the Tailwind config, --color-destructive-text in the Tailwind v4 @theme block, and in the inspector output. The contrast matrix now measures it on the background, the card and the alert tint (CONTRAST_PAIRS, SOLVED_PAIR_KEYS) in place of the destructive fill as text, which removes the 119 destructive-on-destructive_tint rows from the #2885 debt table (691 to 572). Custom CSS should use hsl(var(--destructive-text)) for error text and keep --destructive for fills; see the accessibility guide.
  • djust.PersistentLiveView is a LiveView base class that sets enable_state_snapshot = True, so a project can opt a group of legacy-exposure views in to the session-backed state restore by inheriting from it. A subclass can still set the flag back to False; views with exposure_policy = "explicit" ignore the flag and keep persisting only their state(..., persist=...) fields; djust.C304 still checks every subclass. The default for LiveView is unchanged.

Changed

  • Saved view state is keyed by tenant, and an unknown PRESENCE_BACKEND now fails loudly (#2973). A view that uses TenantMixin keeps its state in two places, and both are now scoped by get_state_key_prefix(), which nothing called before: the state backend (tenant:<id>:<session>_<page>...) and the Django session (liveview_tenant:<id>:<path> and its __private, _components and __sticky__ siblings, read by the HTTP fallback POST and by an enable_state_snapshot reconnect). One session that reached two tenants on the same URL shared one entry in each, so a POST on tenant B ran with tenant A's state. A tenant view with no resolved tenant (tenant_required = False) now saves and loads no state, instead of using the unprefixed key every other view shares. The id in the prefix is percent-encoded. The tenant context key is now render-only for the legacy session save: before, a restore (HTTP fallback POST, snapshot reconnect) replaced self.tenant with its serialized form, so self.tenant.id raised in the handler. Views without TenantMixin keep their keys, including on a project that only runs TenantMiddleware (the middleware resolves HTTP requests only, so scoping by it would key HTTP and WebSocket differently). Upgrade note (one-time reset): the keys for tenant views changed, so the state they saved under the old keys is no longer found. State backend: the first reconnect or page load builds a new Rust view and sends a full render instead of a patch; the orphaned entries expire with SESSION_TTL. Django session: the first HTTP fallback POST, or the first enable_state_snapshot reconnect, finds no saved state and re-runs mount(), so in-page state is lost once; the orphaned liveview_<path> entries stay until the session expires. Nothing needs migrating, and views without TenantMixin are not affected. PRESENCE_BACKEND values other than memory, redis, tenant_memory and tenant_redis (tenant_redis is routed through the global registry since #3047) now raise ImproperlyConfigured on first use instead of falling back to per-process memory, and djust.C019 is now an error (it was a warning), so a typo stops manage.py check instead of silently making presence per-process. ADR-038 persist="server" envelopes are unchanged: bound to the tenant the request resolved at mount and refused across tenants, but not to a tenant set later with set_tenant(). Because C019 is an error, runserver, migrate, test and check refuse to run while a stale PRESENCE_BACKEND is set, even in a project that never uses presence; C019 also fires for PRESENCE_BACKEND = None. 39 regression cases in python/djust/tests/test_tenant_state_keys_2973.py, python/djust/tests/test_tenant_session_state_2973.py and tests/unit/test_tenant_presence_2973.py.

  • Live navigation now falls back to a full page load when the destination's page shell differs from the page the reader is on (#3036). dj-navigate, auto_navigate, live_redirect() and back/forward used to swap only dj-root and keep the previous page's <head> stylesheets and outside-root scripts, so a destination with its own stylesheet or {% block extra_scripts %} rendered unstyled or dead with no error. Every page now carries a short hash of what its template loads (<link rel="stylesheet">, <script> outside dj-root, <style> in <head>, head {% include %} tags) in <meta name="djust-page-shell">; the mount reply to a live_redirect_mount (WebSocket and SSE) carries the destination's. When they differ the client loads the destination's URL as a normal page; same-shell pages keep the in-place swap. Pages with no fingerprint (no <head>, no dj-root/dj-view, unreadable template) behave as before. Reconciling head assets and scripts in place is deferred. See the navigation guide.

  • BREAKING: Python 3.11 is now the minimum supported version; Python 3.10 is dropped (#3059). requires-python is >=3.11 and the 3.10 classifier is gone, so pip install djust on 3.10 now resolves to the 1.2 line, or fails if you pin djust>=1.3. Upgrade path: move the environment to Python 3.11 or newer, or stay on djust>=1.2,<1.3, which keeps receiving fixes. The reason is a security one: every autobahn release from 25.9, including the patched 26.7.1 (Dependabot #157, permessage-deflate bypassing maxMessagePayloadSize after inflation), requires Python 3.11 or newer, so the 3.10 lock entry (autobahn 24.4.2, reached through djust[daphne] → channels[daphne] → daphne) could never be patched. uv.lock now carries a single autobahn entry at 26.7.1, and the reviewed CVE-2026-77528 exception is removed from .github/security/pip-audit-ignore.txt. The release wheel matrices (release.yml, publish.yml) no longer build cp310 wheels, ruff's target-version and mypy's python_version move to 3.11, djust_doctor fails the python_version check below 3.11, and the scaffolders (djust init setup, djust_theme package template) write >=3.11. The 3.10-only tomli fallbacks (theming manifest reader, djust init's dependency reader, scripts/check-test-coverage.py) now import the standard library's tomllib directly. The ABI and wheel strategy (#3255) is a separate decision and is unchanged.

  • The vendored ttyd terminal now ships xterm 6 (#3147). @xterm/xterm 5.5.0 to 6.0.0 and @xterm/addon-fit 0.10.0 to 0.11.0. ttyd_terminal.js needed no change: it only uses Terminal({rows, cols, theme, convertEol}), loadAddon, open, write, onData, rows/cols, FitAddon.fit and dispose, all unchanged in v6. xterm 6 draws its own scrollbar and removes the long-deprecated windowsMode, fastScrollModifier and the flat overviewRulerWidth option; djust passes none of them, but a project that copied the hook and sets one needs to adapt. The bundle, its SRI hashes, djust_assets.json and djust.cdx.json are regenerated with make vendor, and a vitest test now loads the committed bundle through the hook's own import().

  • Presence is stored per connection and aggregated per user and room (#3254). A user stays present until their LAST connection leaves or times out: closing or navigating one tab never removes a user who is open in another. Each view that calls track_presence() is one connection (key (room, user, connection_id)); list_presences(), presence_count() and online_count still have one record per user, with the unchanged shape {"id", "joined_at", "meta"} (joined_at is the earliest connection's, meta the most recently joined connection's). handle_presence_join now fires only when a user's FIRST connection arrives and handle_presence_leave only when their LAST one leaves; before, a second tab fired the join and the first tab to close fired the leave. Each connection has its own heartbeat and 60 s timeout, and the WebSocket ping now refreshes every view mounted on the socket (mount_batch siblings included).

    Contract changes (see the presence guide, "Custom presence backends"):

    • PresenceBackend gains three non-abstract methods: join_connection(key, user_id, connection_id, meta) -> (record, first), leave_connection(key, user_id, connection_id) -> record | None (the record only when it was the user's last connection) and heartbeat_connection(key, user_id, connection_id). Their defaults call the existing join / leave / heartbeat, so a third-party backend written against the old contract keeps working unchanged, with the old one-record-per-user behaviour (a second tab collapses, a close removes the user, the hooks fire on every track and untrack). The abstract methods and their required signatures are unchanged.
    • PresenceBackend.per_connection (False by default, True on the four built-ins) and PresenceManager.per_connection() say whether the configured backend stores one record per connection. Only then is a replaced view's untrack held until the replacement mounts; a backend on the old contract keeps leave, mount, join (its one record is shared), and keeps the #3313 SSE reconnect guard. A built-in subclass that overrides join/leave/heartbeat with the old signature (no connection_id) counts as an old-contract backend and its overrides are called as they always were.
    • The built-in backends' join, leave and heartbeat take an optional connection_id. Omitted, join and heartbeat address the user's one legacy connection (heartbeat refreshes all of the user's), and leave removes every connection of the user and returns the record when the user is gone, None when never there.
    • PresenceManager gains join_connection, leave_connection; join_presence, leave_presence and update_heartbeat take an optional connection_id. leave_presence / leave_connection return None while the user is still present through another connection.
    • Redis (and tenant Redis) keys hold one zset member / hash field per connection (the bare user id for a legacy connection, <user_id>\x1f<connection_id> otherwise), so a record written by an older node reads as one legacy connection (a connection a stopped pod leaves behind lingers up to 60 s beside the restored view's). heartbeat no longer re-creates an expired member (ZADD XX), count() reads member names only (one ZRANGEBYSCORE), and join / leave decide first/last inside one MULTI.
    • PresenceMixin gains _presence_connection_id (per view, regenerated on state restore, never trusted from saved state) and a _on_presence_user_left hook that LiveCursorMixin uses to drop the cursor with the user's last connection. untrack_presence() on a view that joined no connection removes nothing.
    • The WebSocket consumer holds a replaced view's presence untrack until the replacement frame has mounted (_defer_presence_untrack, flushed at the end of receive() and on disconnect); SSE navigation untracks after the new page mounts. A handle_presence_leave hook for a replaced view therefore runs after that view's teardown. A user whose every connection timed out is still removed without calling handle_presence_leave.
  • djust_ai_context carries the login-over-WebSocket and get_context_data() notes (#3304). The generated CLAUDE.md / .cursorrules / copilot text now says, in the Security section, not to call login() in an event handler (over the WebSocket it cannot set the session cookie; use an HTTP login view, the accounts pages, or dj-trigger-action with self.trigger_submit()), and, in a new "State and get_context_data()" section, to set state in mount() and always call super().get_context_data() because the HTTP fallback rebuilds the view from the dict that method returned. Both notes restate docs/website and are pinned for all three formats in python/djust/tests/test_ai_context_command_3297_3292.py.

  • Documented two behaviours that trip up LiveView apps: login() inside an event handler cannot work over the WebSocket (it rotates the session key and a WebSocket can't set the cookie that carries the new one; use an HTTP login view, the accounts pages, or dj-trigger-action + trigger_submit()), and the HTTP page-POST fallback rebuilds the view from the dict get_context_data() returned on the previous request, so call super().get_context_data() and set state in mount(). The template guide now says the root belongs inside <body>, never on <html> (#3304, #3292).

  • AI-context directive text: exact wording and sturdier pins (#3327). schema.DIRECTIVES (the source of the generated CLAUDE.md, .cursorrules and Copilot files) now marks the dj-target example as INERT so the marker travels with the pasteable snippet, gives the dj-trigger-action example the id="checkout-form" its text refers to, and says that dj-prefetch="false" opts out of both prefetch layers. docs/website/guides/loading-states.md no longer says dj-loading.for works "regardless of where that element is in the DOM": it follows an event within the same LiveView or component, as scopeFor enforces. The dj-target pin now counts where _djTargetSelector occurs in the client source instead of matching one literal expression, and the other seven directives corrected in #3291 (dj-loading, dj-loading.for, dj-document-*, dj-prefetch, dj-trigger-action, dj-patch-reload, dj-key) each have a pin on both the corrected wording and the client or parser behaviour it cites (python/djust/tests/test_schema_directive_pins_3327.py).

  • Documented: overriding --destructive while the generator is active does not move error text, and text-destructive in Tailwind is the fill (#3344). The accessibility guide now says to override --destructive-text alongside --destructive (and destructive-text next to a destructive in extra_css_vars), explains why Tailwind's text-destructive stays the fill (DEFAULT is shared with bg-destructive; use text-destructive-text), and states that destructive_text is solved against the page, a card and the destructive washes only: error text on muted, accent or secondary can still be under 4.5:1 (46, 49 and 25 of 136 preset-modes), and solving for them would recolour 70 preset-modes that pass today.

Fixed

  • A tag that names its own closing tag now works under the Rust engine, including through {% extends %}. Two parts. The ADR-030 wrapper probe looked for end<name> rather than the end tag the tag's closure names, so a wrapper such as end_theme_card_block was refused as having more than one body segment (LibraryBlockTagHandler already used the right name at render time). And the inheritance resolver (crates/djust_templates/src/inheritance.rs) re-serialised a block or raw-block custom tag as {% end<name> %} instead of the end tag its handler registered, so a LiveView template that {% extends %} a base and used such a tag failed to parse; it now writes the registered end tag. Needs the Rust extension rebuilt. get_theme_manager also treats a falsy request (the "" a render context without request yields) as no request.
  • Root locators now follow HTML attribute and comment tokenization for quotes in unquoted values, bogus or abruptly closed comments, and unfinished tags. Python and Rust closing walks ignore tag-shaped attribute values and accept end-tag attributes; page-view stamping uses the same token boundaries, while preserving root precedence and embedded-child ownership (#3054).
  • Registry deadlocks during Python handler replacement and removal (#3088). Drop removed handlers after unlocking so their finalizers can re-enter the registry. Contended registry lock waits now detach so Python's GIL and free-threaded global pauses can make progress.
  • Concurrent event timings — Keep each WebSocket event task's performance tracker isolated and restore enclosing trackers after exceptions or cancellation. Awaited sync handlers inherit the event tracker, so track_performance records their timing in the same event summary (#3089).
  • The HTTP fallback routes an embedded child's events to the child (#3104). Over HTTP-only a {% live_render %} child's event reached the parent (since #3159, it was refused instead). The request now renders the page once to register its children, then validates, authorizes (the page's view and object permission, the child's own, @permission_required on the handler) and runs the handler on the child, and answers with the child's own HTML in an embedded_update, as WebSocket and SSE do; a sticky child that opted in to state persistence keeps its state across events. This needs a child's id to repeat between the page GET and the POST that follows, so over HTTP an auto-assigned id is named for what the child is (child_<keyed hash> of its view path and tag arguments, with a numeric suffix for a repeat) instead of coming from a process-wide counter: a page that changes between the GET and the POST (a list that gained a row in another tab) never makes an id name a different child, and an id whose child is gone is refused. An id that names no child, an explicit-exposure child, or a child of an explicit-exposure page is still refused with "Embedded view not found" (an explicit child's turn is authorized against a mount binding a stateless request does not have). A non-sticky child's state is not persisted over HTTP: each request re-creates it, as every parent render always did.
  • link and primary used as text are in the contrast matrix (#3165). link and primary on background and on card, 4.5:1, are now CONTRAST_PAIRS entries, so djust_theming.W001 and the all-presets gate check them (46 preset-modes failed link on the background, 54 failed primary; the legacy palettes' rows cite #2885). The djust preset's link already passes (the first half of #3165). Its light-mode primary stays at 2.65:1 as text and is a documented exemption rather than a recolour: it is the brand orange and carries the dark ink labels of #2996.
  • Back restores the latest rendered state under slow or failing session storage (#3248). A legacy enable_state_snapshot view's per-event save is best effort; a save that was skipped because the previous one was still running, or that failed, left the session older than the last render, and Back restores the session copy in preference to the client's token. The latest state is now written once more by a coalesced trailing save: one write per burst, ordered after the pending save so an older write never lands over a newer one, and waited for (3 s at most; the wait ends once the save in progress has been running for 1 s, so hung storage holds a navigation for about 1 s at most, and a save still running after 1 s is abandoned and Back restores the last stored copy) before a disconnect, live_redirect, replacing mount or SSE close releases the view. It writes nothing into a session that was logged out or whose key changed since the state was rendered. A failing save is retried once and the latest state kept. Restored state is historical view state: handler permission checks still run against the current request.
  • Deferred activity events persist state before acknowledgement (#3253). SSE and WebSocket redispatch now use the normal event persistence and signed snapshot paths, including skipped renders. Explicit views withhold success frames on storage failure. Legacy snapshot opt-in views also receive refreshed session-bound tokens from HTTP fallback responses. Signed-cookie HTTP sessions revoke the token because response middleware changes their session binding.
  • Closing an SSE stream untracks the closed view's presence, and a user open in a second tab is no longer removed by the first tab (#3254). SSESession.shutdown() is synchronous and untrack_presence broadcasts through the synchronous channel-layer API, so the record lingered for PRESENCE_TIMEOUT (60 s) after the stream closed, whatever closed it (client gone, rate-limit close, abandoned stream). shutdown() now schedules the untrack onto a worker thread, or runs it inline when there is no running loop. With presence stored per connection (see the changed entry), the old stream's close after an EventSource reconnect removes only the old view's connection, so the registry/in-flight-mount guard #3313 added for that case is only still needed (and still applied) for a presence backend on the old one-record-per-user contract, and a navigation or close in one tab no longer removes a user who is still open in another. Same-room navigation no longer leaves and rejoins: the replacement view joins before the old view's presence is removed, on both the WebSocket and SSE transports (for a per-connection backend).
  • WebSocket auth refusals — Follow validated auth redirects and stop reconnecting on 4401/4403 refusals, cancelling pending edits and emitting djust:auth-refused on window (#3265).
  • Themed buttons and inputs show keyboard focus again (#3276). .btn:focus-visible and .input:focus removed the outline and set ring:, which is a Tailwind utility and not a CSS property, so focus was invisible (WCAG 2.4.7). They now draw a real outline in the ring colour. The glow focus style of theme packs used the invalid hsla(var(--ring), 0.3) and is now hsl(var(--ring) / 0.3). A new test scans all theming CSS for non-property ring:, an outline removed with no replacement indicator, and the mixed hsla(var()) form. The same scan rejects a border-colour change or a faint halo as the only replacement indicator, so .theme-form-* inputs, .theme-preset-select, the scaffold form inputs and *:focus-visible, the pack glow style and the generated form-input variants also keep a real outline.
  • Every class a theming component tag emits now has a rule in components.css (#3277). {% theme_select %}, {% theme_textarea %}, {% theme_radio %}, {% theme_nav_item %}, {% theme_mode_button %}, the nav group, the preset selectors, the alert actions area and the input/select/textarea help and error text rendered as unstyled browser controls (a white <select> in dark mode). .btn, .input and every other rule that sets a width or height together with padding or a border now carries box-sizing: border-box, so a plain page no longer renders a 58px-tall text input. A test derives the emitted class list from the component templates and tag docstrings, so a new class or variant without a rule fails it.
  • djust_theming.W001 and the all-presets contrast gate now cover the surfaces components paint, and the legal preset passes them (#3281). The matrix measured only *_foreground labels on their fill. It now also checks success / warning / destructive as text on the 10% tint of an alert or toast, and the input border on the background at 3:1 (WCAG 1.4.11), in both modes. ThemeTokens gains read-only success_tint, warning_tint and destructive_tint properties for the derived surface (the status colour at 10% over the page or a card, whichever gives the text less contrast); they are not tokens and no CSS variable is emitted. legal was failing destructive text on its tint (3.2:1 dark), primary on the dark card (3.0:1), white on success (3.2:1 dark, 4.33:1 light), a 1.2:1 input border and light-mode warning text on its tint (2.5:1), plus the muted label on muted; lightness moved, hues did not, and its four old exemptions are gone. The other presets are not recoloured: their 608 new failures are documented rows in A11Y_EXEMPTIONS that cite #2885, regenerated with scripts/report_theme_contrast.py --debt-table. Upgrade note: a user-authored preset can now raise djust_theming.W001 for these pairs.
  • Template system checks (Y003, S011, T001, T012, the root detection, the {% live_render %} scan, C010, C012 and B010) no longer match markup inside {# #} or {% comment %}...{% endcomment %}. Comment bodies are blanked with whitespace before the pattern checks, so reported line numbers are unchanged, and pragmas that live in comments ({# noqa: T003 #}, {# djust:partial #}, {# noqa: B010 #}) still work. One linear-time helper replaces the four divergent comment regexes; it follows Django's lexer, so {% comment "100% sure" %} closes correctly and a multi-line {# #} stays literal text (#3283).
  • Uploads work with default settings again (#3287). allow_upload() and UploadConfig defaulted chunk_size to 64 KiB, which the server sent to the client in preference to the client's own 63 KiB default. A 64 KiB chunk plus its 21-byte frame header is 65,557 bytes, over the default max_message_size (65,536), so every upload larger than one chunk was rejected as "Message too large". The default is now 63 KiB (djust.uploads.DEFAULT_CHUNK_SIZE), and a slot's chunk_size is clamped to max_message_size minus the frame header (with a warning) so a lowered limit or an explicit large value can't reproduce the failure. Resumable uploads that are in flight when a deploy ships this change restart from the beginning: the server's persisted chunk ranges are indexed in 64 KiB units while the client now sends 63 KiB chunks, so a resume across the deploy cannot line up.
  • {% theme_select value=... %} selects the matching option (#3288). value= used to fall into the pass-through and render as an attribute on <select>, which browsers ignore, so an edit form always opened on the first option and saving it changed the field. The option whose value equals it (compared as strings) is now selected; an option dict's own "selected" still works, and the placeholder is selected only when no option is. Regression cases in python/djust/tests/test_theming_component_attrs_3279_3288.py.
  • dj-upload-progress containers are populated, and theme_progress can be wired to an upload (#3289). The client now attaches the upload ref to each [dj-upload-progress="<slot>"] container when an upload starts: an empty container gets a <progress> item per file, and a container that already holds a bar (a .upload-progress-bar, a [role=progressbar] track, or {% theme_progress %}) has that bar driven by the upload. theme_progress passes extra keyword arguments (dj_upload_progress, dj_hook, data_*, ...) through to its wrapper element. A bare <progress dj-upload-progress> is driven directly, an indeterminate theme_progress becomes determinate on the first real percentage, and slot names and refs are escaped before they reach a selector. The dj-upload-progress schema entry (the source of the generated AI context) no longer calls the directive inert.
  • The hidden attribute hides themed components again (#3290). .progress-wrapper and every other component class that sets display: flex|grid|inline-* overrode the user-agent [hidden] { display: none }, so toggling hidden on a progress bar did nothing. components.css now carries one [hidden]:not([hidden="until-found"]) { display: none !important; } guard.
  • The generated AI context now carries security guidance. The CLAUDE.md, .cursorrules and Copilot instructions written by manage.py djust_ai_context have a short ## Security section (a separate _section_security() in python/djust/management/commands/djust_ai_context.py, identical for all three formats). It tells the assistant not to mark user-supplied content safe (|safe, {% autoescape off %}, mark_safe()), to gate views with login_required / permission_required and re-check permissions in event handlers, to include {% csrf_token %}, to set LIVEVIEW_ALLOWED_MODULES in production (including "djust", since an explicit list replaces the default that admits djust's own LiveViews), and to run python manage.py check and python manage.py djust_audit --ast before finishing (naming the X001 to X007 findings the scan emits). It says plainly that these checks do not make generated code secure on their own. Pinned per format, with a test that every audit code it cites exists in djust.audit_ast (#3292).
  • djust.A014 (SECRET_KEY starts with django-insecure-) and the A010-A012 ALLOWED_HOSTS errors no longer abort manage.py test. Django's test runner forces DEBUG = False, which these checks read as production; they now treat a test run as non-production. manage.py check --deploy and real servers still report them (#3293).
  • {% csrf_token %} now renders on the initial page outside dj-root (a logout form in a shared base.html nav). The page shell was rendered without a CSRF token when no csrf context processor was configured, so such a form posted without one and got a 403; the shell now receives the same token the root does (#3294).
  • A stale client.js.backup no longer ships in the package (#3295). The 215 KB unreferenced copy of an old client was tracked in git and included in every sdist and wheel. It is removed, and *.backup is now gitignored and excluded in [tool.maturin], with a test that fails if a tracked *.backup file or either guard goes missing.
  • djust init on a uv project no longer rewrites dependencies the project already declares. It ran uv add 'djust>=…' 'channels>=4.0' 'uvicorn[standard]>=0.30' unconditionally, and uv add replaces an existing specifier, so djust==1.2.2 became djust>=1.2.2 and the channels and uvicorn bounds were dropped. It now reads [project].dependencies (matching by canonical package name, so extras, case and -/_/. spellings count) and runs uv add only for the missing packages; when none are missing it reports packages unchanged and goes straight to manage.py check. --dry-run prints the same trimmed command. A declared package that lacks an extra the scaffold needs (uvicorn without [standard], the WebSocket library) still gets it: uv add 'uvicorn[standard]' with no specifier, which uv resolves by keeping the declared bounds, and an in-place edit of that requirements.txt line. A pyproject.toml that cannot be parsed keeps the old behaviour (#3296).
  • manage.py djust_ai_context no longer overwrites an existing file silently. It opened its target with open(path, "w"), so a hand-written CLAUDE.md, .cursorrules or Copilot instructions file was replaced by the generated file with exit status 0 and nothing of the original left. It now raises a CommandError (exit status 1, file untouched) when the target exists, naming --force, --output and --print as the ways forward; --force replaces the file as before, and --print is unaffected. No backup or marker-section merge: the generated file is meant to be regenerated whole, so the safe default is to refuse. The command is documented in the Scaffolding guide (#3297).
  • The [HotReload] startup banner no longer goes to stdout. With DEBUG=True and watchdog installed, enable_hot_reload() runs on every management command and printed [HotReload] Hot reload enabled for directories: ... to stdout, so manage.py djust_audit --dump-permissions > permissions.yaml (and --json, djust_ai_context --print, manage.py check) began with that line and the file did not parse. The banner is now written to stderr, the failure case goes through logger.error only (it already reached stderr via logging's last-resort handler), and logger.info carries the banner instead of stderr when a logging handler is listening, so it is not printed twice. A subprocess test parses a redirected djust_audit --dump-permissions as YAML (#3298).
  • T019-T022 now run when TEMPLATES lists only DjustTemplateBackend (the djust new default). They previously did nothing, silently, because no Django template engine was found; a compile-only engine is now built from the Djust backend's directories, installed-app tag libraries ({% load static %} and the rest), OPTIONS["libraries"] and builtins. A templatetags module that fails to import is skipped rather than aborting the checks, and if the engine cannot be built at all the scan is skipped. When a project has views but no usable template engine, a new info message djust.T023 says the checks were skipped (silence it with suppress_checks). The same engine feeds the recovery-target scan that strict-policy dispatch consults, so on a Djust-only project a template that compiles now reports a complete answer (it used to report "incomplete" for every view and fall back to parsing each render); a template that does not compile still reports incomplete (#3299).
  • djust WARNING and ERROR records now reach stderr in a project with no LOGGING config. The observability ring-buffer handler on the djust logger had suppressed Python's last-resort handler, so warnings such as the root-on-<html> and template-inheritance fallback messages were silent. The handler now hands WARNING+ djust.* records to logging.lastResort only when no other handler on the logger's propagation chain would receive them, so a configured project does not print twice and INFO stays quiet (#3301).
  • The HTTP event fallback (LiveView.post()) now honours a navigation the handler queued. A live_redirect is returned as a _navigation list of WebSocket-shaped navigation frames and nothing is rendered or saved, so a handler that calls logout() then live_redirect() no longer renders against the anonymous request (a 500 before); a live_patch renders as before and carries its frame. The client hands each frame to djust.navigation.handleNavigation, which does a full-page navigation when no socket is connected (#3303).
  • Context processors ignored when TEMPLATES names the backend as djust.template.backend.DjustTemplateBackend (#3307). ContextMixin._get_context_processors compared BACKEND against one literal spelling, so request, user, perms, messages and every project processor silently never reached the template under the canonical path. It, and the APP_DIRS template-dir collector, now match by class (djust.utils.is_app_dirs_template_backend): either dotted path, a project subclass, or the stock Django backend. 8 regression cases in python/tests/test_template_backend_spelling_3307.py.
  • Live preset switching created an un-nonced <style>, blocked under a strict CSP (#3310). theme_head now publishes its nonce to theme.js (window.__djust_theme_nonce, set by the already-nonced anti-flash script) and gives its own theme <style> / <link> the id="djust-theme-css" that setPresetWithoutReload looks up, so the switch updates the nonced element in place; any <style> theme.js does have to create carries the nonce. The inline onload on the deferred stylesheet is unchanged (use link_css=True or critical_css: False under a strict policy). 12 regression cases in python/djust/tests/test_theming_live_switch_nonce_3310.py, 5 in tests/js/theming_live_switch_csp_nonce_3310.test.js.
  • djust init handles marker-declared and duplicate requirements, and reports extras in -r files (#3312). A pyproject.toml dependency declared with an environment marker (uvicorn>=0.30; python_version>'3.9') no longer gets uv add 'uvicorn[standard]', which appended a second unconditional line instead of keeping the specifier: init reports an ATTENTION step and the extra to add by hand. An extra now counts as present only on an unmarked declaration, so a marked pair no longer adds up to "satisfied" (pyproject.toml and requirements.txt); a marked-only extra in requirements.txt is reported, and an unmarked line still gets the extra in place while marked lines are left alone. An extra missing only in a -r included file, which init does not edit, is reported (ATTENTION step plus a note naming the file) instead of skipped silently. An include cycle spelled with .. (a -r path that goes through ..) no longer recurses forever. 26 regression cases in python/djust/tests/test_djust_init_markers_3312.py.
  • djust_ai_context encoding and partial files, djust init hash-locked requirements, hot-reload banner (#3312). djust_ai_context now writes UTF-8 whatever the locale (under LC_ALL=C PYTHONUTF8=0 the first run died on the em dash after creating the file, leaving a 0-byte CLAUDE.md the rerun then refused), removes a file whose write failed, and with --force writes a unique temp file beside the real target (symlinks are written through) and renames it over, keeping the permission bits, so a failure keeps the original. djust init no longer edits a requirements.txt that carries --hash= (in it or any file it includes): rewriting a line or appending one breaks --require-hashes, so it reports the requirements to add and recompile with hashes. The [HotReload] banner is written to stderr exactly once and never routed through logging, so it no longer vanishes under a non-console handler or a WARNING-level console handler. 21 regression cases in python/djust/tests/test_djust_init_hash_locked_3312.py, python/djust/tests/test_ai_context_encoding_3312.py.
  • {% theme_nav_item %} keeps its active state across client-side navigation when the nav sits outside dj-root. The tag stamps data-dj-nav="<url>" on a link whose state it auto-detected; the client now dispatches djust:path-changed on document whenever the rendered pathname moves (live_redirect, live_patch to a new path, auto-navigate click), and components.js recomputes the active class and aria-current="page" from it and from back/forward. The rule is shared with the server and is now segment-aware (/docs is not active on /docs-old/), tolerant of a missing trailing slash, and percent-decoding on both sides; an explicit active= is left alone, and theme_nav_item no longer raises on url=None. dj-navigate's exact-match aria-current sync skips data-dj-nav links while components.js is loaded (#3318).
  • Error text meets WCAG AA in every preset and both modes (#3320). --destructive is a fill (a deep red such as 0 62% 30% under a white label in dark default, shadcn, blue and green), yet error text was painted with it: 1.7:1 to 1.9:1 on those dark pages, and 51 of 68 presets failed 4.5:1 in dark mode and 42 in light (41 on exact float math). Every place that paints destructive as text (form-field errors, .text-destructive / .text-danger, the text and icon of the destructive alert, toast, badge and tag variants, destructive menu items, the sign-in error) now reads the new derived --destructive-text. The brand palettes are not recoloured (#2885 stays parked). rich-select-trigger--variant-danger also drew its text in --destructive-foreground, the label meant for the fill, which is near-white on its own tint; it now uses the text token. 28 regression cases in python/djust/tests/test_theming_destructive_text_3320.py.
  • {% theme_preset_selector %} keeps showing the active preset after a light/dark toggle. The mode toggle re-synced every preset select from localStorage alone, falling back to "Default", so a preset the visitor never picked in that browser (a configured preset, or a pack) flipped to "Default" while the page still wore it. The select now follows only a preset this page switched to live; otherwise the server-rendered selection stands (#3320).
  • dj-prefetch="false" now opts a link out of the service-worker hover prefetch too (#3327). The attribute used to skip only the intent prefetch (dj-prefetch); the older service-worker path in 22-prefetch.js, which warms any same-origin link under an active service worker, checked only data-no-prefetch, so <a dj-prefetch="false" href="/logout/"> was still prefetched on hover. Both layers now honour it, and data-no-prefetch keeps opting out of the service-worker path. Regression case in tests/js/prefetch.test.js.
  • A dj-model input inserted after the page initialized (by a patch, inside a lazily hydrated dj-lazy view, or by a live_redirect morph) is now bound and sends update_model; before, bindModelElements() ran only at init and reinitAfterDOMUpdate did not call it despite its comment. reinitAfterDOMUpdate now calls it, scoped to the updated subtree. The bind guard stays a JS property on the element (never a DOM attribute a morph would strip), so unchanged inputs are skipped on every pass, and a surviving input whose dj-model attributes a patch changed is rebound (#3334).
  • A dj-lazy / mount_batch view whose container is removed while another container's unmount grace period is running is now unmounted on its own grace period, instead of staying mounted until the next DOM mutation anywhere on the page. _confirmSlotRemoval re-arms the container sweep when a container is still missing, keeping one pending timer at a time (#3335).
  • The theme editor's live preview recomputes --destructive-text (#3344). Switching preset or dragging the destructive, background or card picker in the gallery editor changed --destructive but left error text on the first preset's derived value. The editor now carries a JS port of the solver (solveDestructiveText in editor.html) and re-derives the token on a preset or mode switch and on those three pickers; a test runs the port under node and compares it with _solve_destructive_text for all 68 presets x 2 modes and 400 seeded random inputs. The token stays derived: it is not in TOKEN_FIELDS and not in the exported payload. The W001 hint for the destructive_text pairs no longer says "Adjust destructive_text" (a preset author cannot set it); it names background, card and destructive. The demo project's rentals base.html paints .text-destructive with the token (and defines it per mode). New solver cases put a card far from the page, so dropping the plain card from the solved surfaces now fails a test.
  • The post-event session save merges what the view changed instead of overwriting the whole session (#3347). A legacy enable_state_snapshot view loads its Django session once at mount and its per-event save wrote that whole copy back, so a value another tab, request, middleware or handler stored since (an idle-activity timestamp, a flag) was rolled back and a key deleted elsewhere came back; explicit persist="server" saves had the same window for the length of a turn. The save now records the keys written or removed through the session object it holds (djust's own liveview_* and _djust_explicit_* keys, and a handler's or check_permissions' own request.session writes, including an in-place change to a mutable value that was read), reads the session as storage holds it right before writing, applies only those changes to it and writes the result. It costs one extra session read per save; on db and cached_db sessions whose database can lock a row (not SQLite) the read takes the row lock in the same transaction as the write. A writer that saves its whole session from an older read, and sessions with no row lock (SQLite, cache, file), can still race it between the read and the write. A write made while a save is still running is kept for the next save, and a reference to a list or dict held in the session stays attached across saves. On cached_db the cache is written after the transaction ends. signed_cookies sessions cannot be merged and behave as before, as does the save after a handler's logout(), flush(), clear() or key rotation (merging resumes with the next event). Tested end to end over WebSocket and SSE on db, cache and cached_db, and on file at the save.
  • The generated AI context said dj-submit calls e.target.reset(); it does not. djust_ai_context (CLAUDE.md, .cursorrules, Copilot instructions), djust_schema and the MCP framework schema now say the client only calls e.preventDefault() and resets the first <form> inside [dj-root] when the server response carries reset_form, which FormMixin.reset_form() sets and a plain @event_handler can request with self._should_reset_form = True. FormMixin does not reset automatically after a valid submit. Pinned by three tests in python/djust/tests/test_inert_api_claims.py. Eight other directive descriptions that the client source contradicts were corrected in the same pass: dj-target (marked inert), dj-loading and dj-loading.for, dj-document-* (scroll and resize never fire), dj-prefetch, dj-trigger-action, dj-patch-reload (no effect on dj-navigate) and dj-key. The template cheat sheet no longer lists dj-target as scoping DOM updates.
  • The Django-to-Rust filter bridge no longer marks itself done when its first call finds no Django template engine. A TEMPLATES holding only the djust backend left nothing to walk, yet the one-shot flag was set with nothing recorded, so a root LiveView's {% load live_tags %} filters (field_value, has_errors) failed with Invalid filter for the rest of the process. The bridge now stays armed until an engine exists. This was the order-dependent failure that turned main red on python-tests (shard 1/4).

Security

  • Complete djust admin sign-in through Django's CSRF-protected HTTP login form, rotating the session and CSRF cookies instead of authenticating the existing session over a WebSocket. The login URL, page styling, staff-only access, and safe next-page redirects are preserved.
  • Debug panel and client-dev.js now escape interpolated values. The in-browser debug panel (loaded only when settings.DEBUG is true) built its Events, Network, Patches, Components, Handlers, Variables and Warnings tabs, the element badge, and the hot-reload error toast as HTML strings without escaping, so text from application data (for example another user's input in a shared view) was parsed as markup in the developer's browser. Every interpolated value is now escaped (including attribute positions, via a quote-escaping escapeHtml), warning docs_url links are limited to http(s)/relative URLs, and the toast uses textContent. Production pages (DEBUG = False) never load the panel and were not affected.
  • reauth_on_event now also covers frames the server sends on its own. With LIVEVIEW_CONFIG["reauth_on_event"] on, a WebSocket whose user was logged out or lost a permission was refused on its next client event but kept receiving server_push / push_to_view, db_notify, tick, presence and async-result frames for a view with login_required or permission_required. Each such turn on the default (legacy) exposure policy now runs the same fresh-principal check as an event, under the render lock and before the hook runs; on failure the socket gets navigate-to-login and close 4403 and nothing is rendered or sent. A passed check covers the socket for LIVEVIEW_CONFIG["reauth_server_turn_interval"] seconds (default 5; 0 re-checks every turn), so ticking views do not read the session store on every tick. On a socket that carries several views (the page view plus lazy or batched views) the check runs for the view the turn targets and the throttle is per view; a refused lazy or batched view is torn down on its own and the socket stays up for the others. A check that raises denies and logs only the exception type and view class at WARNING. Off by default; behaviour with reauth_on_event off, and for explicit-policy views, is unchanged. Tests in python/djust/tests/test_server_turn_reauth.py.
  • The reauth_on_event re-check now denies when it raises instead of allowing the event. With LIVEVIEW_CONFIG["reauth_on_event"] on, an exception other than PermissionDenied from the per-event authorization check (for example Http404 or DoesNotExist from check_permissions(), or a failing session or authentication backend) was logged at DEBUG and the event ran anyway. The WebSocket and SSE transports now log the exception type and view class at WARNING (never the message) and take the normal refusal path: navigate to login and close 4403 on WebSocket, an auth-error frame and end of stream on SSE. Clean allow and deny results are unchanged, and reauth_on_event stays off by default. Regression cases in python/djust/tests/test_reauth_fail_closed.py.
  • The signed state_snapshot token of a TenantMixin view is bound to its tenant. A back-navigation token (enable_state_snapshot = True) was bound to the view and the session; it is now also bound to the tenant that minted it. A mount under a different tenant refuses it and runs mount() fresh, and a tenant view with no resolved tenant neither mints nor accepts one. Views without TenantMixin are unchanged. Tokens that tenant views issued before the upgrade are refused once (one fresh mount, then a new token). ModelFormMixin now chains _djust_render_only_context_keys() through super(), so listing it before TenantMixin keeps the tenant key render-only (#3328).

Documentation

  • AI Guardrails guide (docs/website/guides/ai-guardrails.md). One page that lists the checks and tooling that catch mistakes an AI coding assistant makes in a LiveView app, with the command that runs each and what it does not cover: djust_ai_context and the docs/ai/ pages, the MCP server's code checks, the manage.py check families with a table of the checks most relevant to generated code (generated from python/djust/checks/), the six djust_audit modes, and the framework defaults and opt-ins. The MCP server and djust_audit guides link to it.

All releases · Atom feed