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|safeslot. 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) andtheme_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 itsslot_*spelling) too is aTemplateSyntaxError. 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-themecard.htmloverrides are the inline form's. They work in LiveView (Rust-rendered) templates through the{% load %}bridge.djust_theme init --with-examplesnow 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_blockrefuses positional arguments (the first positional oftheme_alertis the message the block fills), and the block forms do not supportas <variable>. Regression cases inpython/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 acrosslive_redirectis 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 adj-lazyview 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 amount_batchwas live. Each view amountframe names with atarget_id(the lazy hydration of the stock client, which names the container), and eachmount_batchentry, 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 pagemount(notarget_id) andlive_redirectstill replace every view. The rest of the verbs are routed by the frame'starget_id:event,url_change,request_html, uploads, presence heartbeats, and the newunmount(tears one view down, the others stay live). Server pushes, presence events anddb_notifyreach the views that joined the group they were sent to (push_to_viewand presence broadcasts now carry the group), and every frame a view sends is stamped with itstarget_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, andLIVEVIEW_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 fromdj-input,dj-model,dj-poll, forms anddj-hookpushEvent, and theupload_register/upload_resumeframes 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, onedb_notifychannel, 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 rawgroup_sendof aserver_push,presence_eventordb_notifymessage that names nogroupis dropped when more than one view is mounted on the socket (it cannot be told apart per view);push_to_view,PresenceManagerand thedb_notifylistener 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 inpython/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) andhref=/element=render an<a class="btn ...">for link-styled buttons.{% theme_select %}takes the same pass-through.{% theme_input %},{% theme_select %}and{% theme_textarea %}acceptid=so repeated fields get unique ids (default staysname). The shared pass-through now validates attribute names and refuseson*event-handler attributes andjavascript:/vbscript:/data:URLs withValueError; upgrade note:{% theme_input %}and{% theme_progress %}used to emit anonfocus=oronchange=keyword silently, they now raise (use adj-*binding). On a link-styled buttontype=raises anddisabled=Truerendersaria-disabled="true" tabindex="-1"withouthref;djust_theme check-compatwarns about a projectbutton.htmlthat ignoresextra_attrs,hrefandtag. Not included: block-tag forms oftheme_card/theme_alert, which stay with #2894. Regression cases inpython/djust/tests/test_theming_component_attrs_3279_3288.py. {% theme_alert %}gains aninfovariant and aroleparameter (#3280).variant="info"rendersalert-info(theinfotoken at 10% tint, incomponents.cssand the Tailwind output) androle=takesalert,statusorlog. The default isalertfordestructiveandwarningandstatusfor the rest, so upgrade note: adefault,successorinfoalert now rendersrole="status"(polite) instead ofrole="alert"; passrole="alert"to keep the old behaviour.infotext on its tint joins the contrast matrix (CONTRAST_PAIRS,ThemeTokens.info_tint); thelegaldarkinfocolour was lightened to pass, and the other presets' 83 failures are documented rows citing #2885. Regression cases inpython/djust/tests/test_theming_alert_info_role_3280.py.- New system check
djust.T024warns when a template a LiveView or LiveComponent renders (a template file, its parents and includes, or an inlinetemplatestring) readsis_staff,is_superuserorpasswordthrough auservariable 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 isrequest.csp_noncewhen 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 andThemeMixin.theme_headcarry it too. With no nonce the output is unchanged, and the script is otherwise byte-identical.build_theme_head_context()gains anonceargument and returns anonce_attrkey. Known limit: the deferred-stylesheet<link>still uses an inlineonload, which a nonce does not authorise. Regression cases inpython/djust/tests/test_theming_head_nonce_3284.py.{% theme_preset_selector presets="legal,medical,default" %}andLIVEVIEW_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 raisesValueError. 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 newdjust_theming.W003system check reports it. The setting also limits the presets intheme_panelandtheme_switcherand thetheme_presetscontext variable. Regression cases inpython/djust/tests/test_theming_preset_allow_list_3285.py.manage.py checkhas a new warning,djust.T025, fordj-view/dj-rooton<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.T002no 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
codeon every transport, and reach the page without parsing message text (#3319). The refusal frames that lacked one (a@permission_requiredhandler, a mount refused withPermissionDenied) and the HTTP fallback's 403 bodies now carry"code": "permission_denied"next to the unchangederrortext, as the revoked-authority and object-level frames already did. The SSEreauth_on_eventframe sent the numeric close code4403as itscode; it now sends"permission_denied". On the client,djust:errorgainsdetail.code(WebSocket, SSE and HTTP fallback) anddjust:auth-refusedgainsdetail.error_code, the code of the refusal frame that preceded the 4401/4403 close (nullwhen there was none);detail.codestays the numeric close code. Documented in the authentication guide ("Refusal codes"). Regression cases inpython/djust/tests/test_auth_refusal_codes_3319.pyandtests/js/auth-refusal-code-3319.test.js. ThemeTokens.destructive_textand the--destructive-textCSS variable (#3320). A read-only token derived per preset and mode: the preset'sdestructivehue 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. Adestructivethat already reads is kept exactly. It is emitted wherever the theme variables are (:root,html[data-theme], the system-preference block), astext-destructive-textin the Tailwind config,--color-destructive-textin the Tailwind v4@themeblock, 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 thedestructivefill as text, which removes the 119destructive-on-destructive_tintrows from the #2885 debt table (691 to 572). Custom CSS should usehsl(var(--destructive-text))for error text and keep--destructivefor fills; see the accessibility guide.djust.PersistentLiveViewis aLiveViewbase class that setsenable_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 toFalse; views withexposure_policy = "explicit"ignore the flag and keep persisting only theirstate(..., persist=...)fields;djust.C304still checks every subclass. The default forLiveViewis unchanged.
Changed
Saved view state is keyed by tenant, and an unknown
PRESENCE_BACKENDnow fails loudly (#2973). A view that usesTenantMixinkeeps its state in two places, and both are now scoped byget_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,_componentsand__sticky__siblings, read by the HTTP fallback POST and by anenable_state_snapshotreconnect). 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) replacedself.tenantwith its serialized form, soself.tenant.idraised in the handler. Views withoutTenantMixinkeep their keys, including on a project that only runsTenantMiddleware(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 withSESSION_TTL. Django session: the first HTTP fallback POST, or the firstenable_state_snapshotreconnect, finds no saved state and re-runsmount(), so in-page state is lost once; the orphanedliveview_<path>entries stay until the session expires. Nothing needs migrating, and views withoutTenantMixinare not affected.PRESENCE_BACKENDvalues other thanmemory,redis,tenant_memoryandtenant_redis(tenant_redisis routed through the global registry since #3047) now raiseImproperlyConfiguredon first use instead of falling back to per-process memory, anddjust.C019is now an error (it was a warning), so a typo stopsmanage.py checkinstead of silently making presence per-process. ADR-038persist="server"envelopes are unchanged: bound to the tenant the request resolved at mount and refused across tenants, but not to a tenant set later withset_tenant(). Because C019 is an error,runserver,migrate,testandcheckrefuse to run while a stalePRESENCE_BACKENDis set, even in a project that never uses presence; C019 also fires forPRESENCE_BACKEND = None. 39 regression cases inpython/djust/tests/test_tenant_state_keys_2973.py,python/djust/tests/test_tenant_session_state_2973.pyandtests/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 onlydj-rootand 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>outsidedj-root,<style>in<head>, head{% include %}tags) in<meta name="djust-page-shell">; themountreply to alive_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>, nodj-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-pythonis>=3.11and the 3.10 classifier is gone, sopip install djuston 3.10 now resolves to the 1.2 line, or fails if you pindjust>=1.3. Upgrade path: move the environment to Python 3.11 or newer, or stay ondjust>=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-deflatebypassingmaxMessagePayloadSizeafter inflation), requires Python 3.11 or newer, so the 3.10 lock entry (autobahn 24.4.2, reached throughdjust[daphne]→channels[daphne]→daphne) could never be patched.uv.locknow carries a single autobahn entry at 26.7.1, and the reviewedCVE-2026-77528exception is removed from.github/security/pip-audit-ignore.txt. The release wheel matrices (release.yml,publish.yml) no longer build cp310 wheels, ruff'starget-versionand mypy'spython_versionmove to 3.11,djust_doctorfails thepython_versioncheck below 3.11, and the scaffolders (djust initsetup,djust_themepackage template) write>=3.11. The 3.10-onlytomlifallbacks (theming manifest reader,djust init's dependency reader,scripts/check-test-coverage.py) now import the standard library'stomllibdirectly. The ABI and wheel strategy (#3255) is a separate decision and is unchanged.The vendored ttyd terminal now ships xterm 6 (#3147).
@xterm/xterm5.5.0 to 6.0.0 and@xterm/addon-fit0.10.0 to 0.11.0.ttyd_terminal.jsneeded no change: it only usesTerminal({rows, cols, theme, convertEol}),loadAddon,open,write,onData,rows/cols,FitAddon.fitanddispose, all unchanged in v6. xterm 6 draws its own scrollbar and removes the long-deprecatedwindowsMode,fastScrollModifierand the flatoverviewRulerWidthoption; 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.jsonanddjust.cdx.jsonare regenerated withmake vendor, and a vitest test now loads the committed bundle through the hook's ownimport().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()andonline_countstill have one record per user, with the unchanged shape{"id", "joined_at", "meta"}(joined_atis the earliest connection's,metathe most recently joined connection's).handle_presence_joinnow fires only when a user's FIRST connection arrives andhandle_presence_leaveonly 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_batchsiblings included).Contract changes (see the presence guide, "Custom presence backends"):
PresenceBackendgains 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) andheartbeat_connection(key, user_id, connection_id). Their defaults call the existingjoin/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) andPresenceManager.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 overridesjoin/leave/heartbeatwith the old signature (noconnection_id) counts as an old-contract backend and its overrides are called as they always were.- The built-in backends'
join,leaveandheartbeattake an optionalconnection_id. Omitted,joinandheartbeataddress the user's one legacy connection (heartbeat refreshes all of the user's), andleaveremoves every connection of the user and returns the record when the user is gone,Nonewhen never there. PresenceManagergainsjoin_connection,leave_connection;join_presence,leave_presenceandupdate_heartbeattake an optionalconnection_id.leave_presence/leave_connectionreturnNonewhile 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).heartbeatno longer re-creates an expired member (ZADD XX),count()reads member names only (oneZRANGEBYSCORE), andjoin/leavedecide first/last inside oneMULTI. PresenceMixingains_presence_connection_id(per view, regenerated on state restore, never trusted from saved state) and a_on_presence_user_lefthook thatLiveCursorMixinuses 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 ofreceive()and on disconnect); SSE navigation untracks after the new page mounts. Ahandle_presence_leavehook for a replaced view therefore runs after that view's teardown. A user whose every connection timed out is still removed without callinghandle_presence_leave.
djust_ai_contextcarries the login-over-WebSocket andget_context_data()notes (#3304). The generatedCLAUDE.md/.cursorrules/ copilot text now says, in the Security section, not to calllogin()in an event handler (over the WebSocket it cannot set the session cookie; use an HTTP login view, the accounts pages, ordj-trigger-actionwithself.trigger_submit()), and, in a new "State andget_context_data()" section, to set state inmount()and always callsuper().get_context_data()because the HTTP fallback rebuilds the view from the dict that method returned. Both notes restatedocs/websiteand are pinned for all three formats inpython/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, ordj-trigger-action+trigger_submit()), and the HTTP page-POST fallback rebuilds the view from the dictget_context_data()returned on the previous request, so callsuper().get_context_data()and set state inmount(). 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,.cursorrulesand Copilot files) now marks thedj-targetexample as INERT so the marker travels with the pasteable snippet, gives thedj-trigger-actionexample theid="checkout-form"its text refers to, and says thatdj-prefetch="false"opts out of both prefetch layers.docs/website/guides/loading-states.mdno longer saysdj-loading.forworks "regardless of where that element is in the DOM": it follows an event within the same LiveView or component, asscopeForenforces. Thedj-targetpin now counts where_djTargetSelectoroccurs 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
--destructivewhile the generator is active does not move error text, andtext-destructivein Tailwind is the fill (#3344). The accessibility guide now says to override--destructive-textalongside--destructive(anddestructive-textnext to adestructiveinextra_css_vars), explains why Tailwind'stext-destructivestays the fill (DEFAULTis shared withbg-destructive; usetext-destructive-text), and states thatdestructive_textis solved against the page, a card and the destructive washes only: error text onmuted,accentorsecondarycan 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 forend<name>rather than the end tag the tag's closure names, so a wrapper such asend_theme_card_blockwas refused as having more than one body segment (LibraryBlockTagHandleralready 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_manageralso treats a falsy request (the""a render context withoutrequestyields) 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_performancerecords 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_requiredon the handler) and runs the handler on the child, and answers with the child's own HTML in anembedded_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. linkandprimaryused as text are in the contrast matrix (#3165).linkandprimaryonbackgroundand oncard, 4.5:1, are nowCONTRAST_PAIRSentries, sodjust_theming.W001and the all-presets gate check them (46 preset-modes failedlinkon the background, 54 failedprimary; the legacy palettes' rows cite #2885). Thedjustpreset'slinkalready passes (the first half of #3165). Its light-modeprimarystays 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_snapshotview'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 anduntrack_presencebroadcasts through the synchronous channel-layer API, so the record lingered forPRESENCE_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 thechangedentry), 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-refusedonwindow(#3265). - Themed buttons and inputs show keyboard focus again (#3276).
.btn:focus-visibleand.input:focusremoved the outline and setring:, which is a Tailwind utility and not a CSS property, so focus was invisible (WCAG 2.4.7). They now draw a realoutlinein the ring colour. Theglowfocus style of theme packs used the invalidhsla(var(--ring), 0.3)and is nowhsl(var(--ring) / 0.3). A new test scans all theming CSS for non-propertyring:, an outline removed with no replacement indicator, and the mixedhsla(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 packglowstyle and the generatedform-inputvariants 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,.inputand every other rule that sets a width or height together with padding or a border now carriesbox-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.W001and the all-presets contrast gate now cover the surfaces components paint, and thelegalpreset passes them (#3281). The matrix measured only*_foregroundlabels on their fill. It now also checkssuccess/warning/destructiveas text on the 10% tint of an alert or toast, and theinputborder on the background at 3:1 (WCAG 1.4.11), in both modes.ThemeTokensgains read-onlysuccess_tint,warning_tintanddestructive_tintproperties 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.legalwas failing destructive text on its tint (3.2:1 dark),primaryon the dark card (3.0:1), white onsuccess(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 inA11Y_EXEMPTIONSthat cite #2885, regenerated withscripts/report_theme_contrast.py --debt-table. Upgrade note: a user-authored preset can now raisedjust_theming.W001for these pairs.- Template system checks (
Y003,S011,T001,T012, the root detection, the{% live_render %}scan,C010,C012andB010) 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()andUploadConfigdefaultedchunk_sizeto 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 defaultmax_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'schunk_sizeis clamped tomax_message_sizeminus 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 whosevalueequals it (compared as strings) is nowselected; an option dict's own"selected"still works, and the placeholder is selected only when no option is. Regression cases inpython/djust/tests/test_theming_component_attrs_3279_3288.py.dj-upload-progresscontainers are populated, andtheme_progresscan 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_progresspasses extra keyword arguments (dj_upload_progress,dj_hook,data_*, ...) through to its wrapper element. A bare<progress dj-upload-progress>is driven directly, an indeterminatetheme_progressbecomes determinate on the first real percentage, and slot names and refs are escaped before they reach a selector. Thedj-upload-progressschema entry (the source of the generated AI context) no longer calls the directive inert.- The
hiddenattribute hides themed components again (#3290)..progress-wrapperand every other component class that setsdisplay: flex|grid|inline-*overrode the user-agent[hidden] { display: none }, so togglinghiddenon a progress bar did nothing.components.cssnow carries one[hidden]:not([hidden="until-found"]) { display: none !important; }guard. - The generated AI context now carries security guidance. The
CLAUDE.md,.cursorrulesand Copilot instructions written bymanage.py djust_ai_contexthave a short## Securitysection (a separate_section_security()inpython/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 withlogin_required/permission_requiredand re-check permissions in event handlers, to include{% csrf_token %}, to setLIVEVIEW_ALLOWED_MODULESin production (including"djust", since an explicit list replaces the default that admits djust's own LiveViews), and to runpython manage.py checkandpython manage.py djust_audit --astbefore 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 indjust.audit_ast(#3292). djust.A014(SECRET_KEYstarts withdjango-insecure-) and theA010-A012ALLOWED_HOSTSerrors no longer abortmanage.py test. Django's test runner forcesDEBUG = False, which these checks read as production; they now treat atestrun as non-production.manage.py check --deployand real servers still report them (#3293).{% csrf_token %}now renders on the initial page outsidedj-root(a logout form in a sharedbase.htmlnav). The page shell was rendered without a CSRF token when nocsrfcontext 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.backupno 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*.backupis now gitignored and excluded in[tool.maturin], with a test that fails if a tracked*.backupfile or either guard goes missing. djust initon a uv project no longer rewrites dependencies the project already declares. It ranuv add 'djust>=…' 'channels>=4.0' 'uvicorn[standard]>=0.30'unconditionally, anduv addreplaces an existing specifier, sodjust==1.2.2becamedjust>=1.2.2and thechannelsanduvicornbounds were dropped. It now reads[project].dependencies(matching by canonical package name, so extras, case and-/_/.spellings count) and runsuv addonly for the missing packages; when none are missing it reportspackages unchangedand goes straight tomanage.py check.--dry-runprints the same trimmed command. A declared package that lacks an extra the scaffold needs (uvicornwithout[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 thatrequirements.txtline. Apyproject.tomlthat cannot be parsed keeps the old behaviour (#3296).manage.py djust_ai_contextno longer overwrites an existing file silently. It opened its target withopen(path, "w"), so a hand-writtenCLAUDE.md,.cursorrulesor Copilot instructions file was replaced by the generated file with exit status 0 and nothing of the original left. It now raises aCommandError(exit status 1, file untouched) when the target exists, naming--force,--outputand--printas the ways forward;--forcereplaces the file as before, and--printis 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. WithDEBUG=Trueandwatchdoginstalled,enable_hot_reload()runs on every management command and printed[HotReload] Hot reload enabled for directories: ...to stdout, somanage.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 throughlogger.erroronly (it already reached stderr via logging's last-resort handler), andlogger.infocarries the banner instead of stderr when a logging handler is listening, so it is not printed twice. A subprocess test parses a redirecteddjust_audit --dump-permissionsas YAML (#3298). T019-T022now run whenTEMPLATESlists onlyDjustTemplateBackend(thedjust newdefault). 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. Atemplatetagsmodule 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 messagedjust.T023says the checks were skipped (silence it withsuppress_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
LOGGINGconfig. The observability ring-buffer handler on thedjustlogger 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 tologging.lastResortonly 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. Alive_redirectis returned as a_navigationlist of WebSocket-shapednavigationframes and nothing is rendered or saved, so a handler that callslogout()thenlive_redirect()no longer renders against the anonymous request (a 500 before); alive_patchrenders as before and carries its frame. The client hands each frame todjust.navigation.handleNavigation, which does a full-page navigation when no socket is connected (#3303). - Context processors ignored when
TEMPLATESnames the backend asdjust.template.backend.DjustTemplateBackend(#3307).ContextMixin._get_context_processorscomparedBACKENDagainst one literal spelling, sorequest,user,perms,messagesand 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 inpython/tests/test_template_backend_spelling_3307.py. - Live preset switching created an un-nonced
<style>, blocked under a strict CSP (#3310).theme_headnow publishes its nonce totheme.js(window.__djust_theme_nonce, set by the already-nonced anti-flash script) and gives its own theme<style>/<link>theid="djust-theme-css"thatsetPresetWithoutReloadlooks up, so the switch updates the nonced element in place; any<style>theme.js does have to create carries the nonce. The inlineonloadon the deferred stylesheet is unchanged (uselink_css=Trueorcritical_css: Falseunder a strict policy). 12 regression cases inpython/djust/tests/test_theming_live_switch_nonce_3310.py, 5 intests/js/theming_live_switch_csp_nonce_3310.test.js. djust inithandles marker-declared and duplicate requirements, and reports extras in-rfiles (#3312). Apyproject.tomldependency declared with an environment marker (uvicorn>=0.30; python_version>'3.9') no longer getsuv add 'uvicorn[standard]', which appended a second unconditional line instead of keeping the specifier:initreports 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-rincluded file, whichinitdoes not edit, is reported (ATTENTION step plus a note naming the file) instead of skipped silently. An include cycle spelled with..(a-rpath that goes through..) no longer recurses forever. 26 regression cases inpython/djust/tests/test_djust_init_markers_3312.py.djust_ai_contextencoding and partial files,djust inithash-locked requirements, hot-reload banner (#3312).djust_ai_contextnow writes UTF-8 whatever the locale (underLC_ALL=C PYTHONUTF8=0the first run died on the em dash after creating the file, leaving a 0-byteCLAUDE.mdthe rerun then refused), removes a file whose write failed, and with--forcewrites 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 initno longer edits arequirements.txtthat 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 inpython/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 outsidedj-root. The tag stampsdata-dj-nav="<url>"on a link whose state it auto-detected; the client now dispatchesdjust:path-changedondocumentwhenever the rendered pathname moves (live_redirect, live_patch to a new path, auto-navigate click), andcomponents.jsrecomputes theactiveclass andaria-current="page"from it and from back/forward. The rule is shared with the server and is now segment-aware (/docsis not active on/docs-old/), tolerant of a missing trailing slash, and percent-decoding on both sides; an explicitactive=is left alone, andtheme_nav_itemno longer raises onurl=None.dj-navigate's exact-matcharia-currentsync skipsdata-dj-navlinks whilecomponents.jsis loaded (#3318).- Error text meets WCAG AA in every preset and both modes (#3320).
--destructiveis a fill (a deep red such as0 62% 30%under a white label in darkdefault,shadcn,blueandgreen), 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-dangeralso 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 inpython/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 fromlocalStoragealone, falling back to "Default", so a preset the visitor never picked in that browser (a configuredpreset, 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 in22-prefetch.js, which warms any same-origin link under an active service worker, checked onlydata-no-prefetch, so<a dj-prefetch="false" href="/logout/">was still prefetched on hover. Both layers now honour it, anddata-no-prefetchkeeps opting out of the service-worker path. Regression case intests/js/prefetch.test.js.- A
dj-modelinput inserted after the page initialized (by a patch, inside a lazily hydrateddj-lazyview, or by alive_redirectmorph) is now bound and sendsupdate_model; before,bindModelElements()ran only at init andreinitAfterDOMUpdatedid not call it despite its comment.reinitAfterDOMUpdatenow 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 whosedj-modelattributes a patch changed is rebound (#3334). - A
dj-lazy/mount_batchview 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._confirmSlotRemovalre-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 thedestructive,backgroundorcardpicker in the gallery editor changed--destructivebut left error text on the first preset's derived value. The editor now carries a JS port of the solver (solveDestructiveTextineditor.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_textfor all 68 presets x 2 modes and 400 seeded random inputs. The token stays derived: it is not inTOKEN_FIELDSand not in the exported payload. TheW001hint for thedestructive_textpairs no longer says "Adjust destructive_text" (a preset author cannot set it); it namesbackground,cardanddestructive. The demo project's rentalsbase.htmlpaints.text-destructivewith the token (and defines it per mode). New solver cases put acardfar 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_snapshotview 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; explicitpersist="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 ownliveview_*and_djust_explicit_*keys, and a handler's orcheck_permissions' ownrequest.sessionwrites, 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; ondbandcached_dbsessions 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. Oncached_dbthe cache is written after the transaction ends.signed_cookiessessions cannot be merged and behave as before, as does the save after a handler'slogout(),flush(),clear()or key rotation (merging resumes with the next event). Tested end to end over WebSocket and SSE ondb,cacheandcached_db, and onfileat the save. - The generated AI context said
dj-submitcallse.target.reset(); it does not.djust_ai_context(CLAUDE.md,.cursorrules, Copilot instructions),djust_schemaand the MCP framework schema now say the client only callse.preventDefault()and resets the first<form>inside[dj-root]when the server response carriesreset_form, whichFormMixin.reset_form()sets and a plain@event_handlercan request withself._should_reset_form = True.FormMixindoes not reset automatically after a valid submit. Pinned by three tests inpython/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-loadinganddj-loading.for,dj-document-*(scroll and resize never fire),dj-prefetch,dj-trigger-action,dj-patch-reload(no effect ondj-navigate) anddj-key. The template cheat sheet no longer listsdj-targetas scoping DOM updates. - The Django-to-Rust filter bridge no longer marks itself done when its first call finds no Django template engine. A
TEMPLATESholding 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 withInvalid filterfor the rest of the process. The bridge now stays armed until an engine exists. This was the order-dependent failure that turnedmainred onpython-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.jsnow escape interpolated values. The in-browser debug panel (loaded only whensettings.DEBUGis 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-escapingescapeHtml), warningdocs_urllinks are limited to http(s)/relative URLs, and the toast usestextContent. Production pages (DEBUG = False) never load the panel and were not affected. reauth_on_eventnow also covers frames the server sends on its own. WithLIVEVIEW_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 receivingserver_push/push_to_view,db_notify,tick, presence and async-result frames for a view withlogin_requiredorpermission_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 forLIVEVIEW_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 withreauth_on_eventoff, and for explicit-policy views, is unchanged. Tests inpython/djust/tests/test_server_turn_reauth.py.- The
reauth_on_eventre-check now denies when it raises instead of allowing the event. WithLIVEVIEW_CONFIG["reauth_on_event"]on, an exception other thanPermissionDeniedfrom the per-event authorization check (for exampleHttp404orDoesNotExistfromcheck_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, andreauth_on_eventstays off by default. Regression cases inpython/djust/tests/test_reauth_fail_closed.py. - The signed
state_snapshottoken of aTenantMixinview 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 runsmount()fresh, and a tenant view with no resolved tenant neither mints nor accepts one. Views withoutTenantMixinare unchanged. Tokens that tenant views issued before the upgrade are refused once (one fresh mount, then a new token).ModelFormMixinnow chains_djust_render_only_context_keys()throughsuper(), so listing it beforeTenantMixinkeeps thetenantkey 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_contextand thedocs/ai/pages, the MCP server's code checks, themanage.py checkfamilies with a table of the checks most relevant to generated code (generated frompython/djust/checks/), the sixdjust_auditmodes, and the framework defaults and opt-ins. The MCP server anddjust_auditguides link to it.