Before you upgrade, read Deprecated below and the upgrade guide.
The eighth 1.3 release candidate. It includes security fixes; upgrading from 1.3.0rc7 is recommended.
Added
Derived
--primary-text,--brand-text,--info-text,--success-textand--warning-textcolours, so a fill read as text meets AA in every preset and mode (#2885).primaryand the status colours are fills (a*_foregroundlabel sits on them), so many presets left them unreadable as text, for example a bright orange link on white. Like--destructive-text(#3320), each is the fill's hue and saturation at the lightness that reaches 4.5:1 on the page, a card and the fill's own 10% and 15% washes, and the fill itself when it already reads. The fills (--primary,--success, ...) and their labels are byte-identical in the generated CSS of all 68 presets.ThemeTokens.primary_text... in Python,--primary-text... in the theme CSS,text-primary-textin Tailwind, the gallery editor's live preview and the inspector. 26 regression cases inpython/djust/tests/test_theming_derived_text_2885.py.ImageLightbox, FileTree, ResizablePanel and AnimatedNumber now ship the interactions their docstrings describe (#2985, batch 2). Each rendered a
dj-hookthat no shipped script answered;components/static/djust_components/image-lightbox.js,file-tree.js,resizable-panel.jsandanimated-number.jsnow do, and the catalogue page of each includes its script. The lightbox takes focus, traps Tab, locks page scroll, closes on Escape, steps on the arrow keys or a swipe, announces the new image and gives focus back; each of those presses the component's own Close, Previous or Next control, so it sends no event of its own. FileTree expands and collapses folders on the page, remembering each choice by the folder's name path so a re-render that inserts, removes or reorders rows leaves every folder (and the reader's keyboard focus) where it was, and follows the WAI-ARIA tree keyboard pattern, with Enter on a file firing the row's own event; a 10,000-row tree stays responsive. ResizablePanel resizes by pointer drag or keyboard as an ARIA window splitter (mirrored in a right-to-left context), clamped tomin_sizeandmax_size, and announces the size as a bubblingdj-resizeevent. AnimatedNumber counts up and always ends on the exact text the server rendered. No markup changes: a byte comparison of 564 renders across the component class, template tag and Rust handler against the previous release is identical. The scripts register inwindow.DjustHooksand only when neither hook registry already holds that name, so an app's own hook is never replaced. 12 components from the issue remain.ActivityFeed, Terminal and Tour now ship the interactions their docstrings describe (#2985, batch 3). Each rendered a
dj-hookthat no shipped script answered;components/static/djust_components/activity-feed.js,terminal.jsandtour.jsnow do, and the catalogue page of each includes its script. A streaming ActivityFeed puts pushed events at the top, keepsmax_itemsrows, announces new activity and follows the WAI-ARIA feed keyboard pattern. Terminal appends streamed lines with the same ANSI colour and bold the server renders (flat spans built with the DOM API, bounded work per line), follows the newest line until the reader scrolls up, and gains an optionalmax_lines. Tour dims the page around the target with a cut-out and ring, places the popover beside it, follows scrolling and resizing, falls back to a centred popover when the target is missing, traps focus and returns it, lets a field in the spotlight keep focus, and answers the arrow keys and Escape by pressing its own buttons; it stays aligned inside a transformed ancestor. Markup changes are additive only:data-max-itemson a streaming feed anddata-line-numbers/data-max-lineson a terminal, on the component class, template tag and Rust handler alike; a byte comparison of 945 renders against the previous release is identical once those attributes are removed. The scripts register inwindow.DjustHooksand only when neither hook registry already holds that name, so an app's own hook is never replaced. 9 components from the issue remain.DashboardGrid, MentionsInput, CursorsOverlay and CollabSelection now ship the interactions their docstrings describe (#2985, batch 4). Each rendered a
dj-hookthat no shipped script answered;components/static/djust_components/dashboard-grid.js,mentions-input.js,cursors-overlay.jsandcollab-selection.jsnow do, and the catalogue page of each includes its script.DashboardGrid: drag a panel by its header to move it and its bottom handle to resize it (also by touch, and in right-to-left or scaled grids), with the arrow keys on a focused panel (Space/Enter grabs, arrows move, Shift+arrows resize, Enter drops, Escape cancels); a dashed cell previews the drop and the server stays the authority, receiving{id, col, row}throughmove_eventand{id, width, height}throughresize_eventin whole grid units (untrusted: the docstring has handlers that validate and clamp them).MentionsInput: typing@offers the people inusers, filtered by the start of any word of their name, as a proper combobox; Enter or Tab or a click inserts@Name, and Enter submits{text, mentions: [ids]}(the ids of the people still mentioned, with the plain input event'svalue,field,keyandcodekept; untrusted).CursorsOverlay: keeps name labels inside the overlay and off each other, announces people appearing and disappearing, and eases cursors unless the user prefers reduced motion; it stays presentational, fed by the app's presence data.CollabSelectiongains an optionaltargetselector: with it the selections'start/endoffsets index the text of that element, the ranges are highlighted in the page's text with the CSS Custom Highlight API (no page node is touched) and each name is placed above its range and follows scrolling and edits; without it the component shows each selection's text where it is rendered, as before. Dashboard coordinates are bounded on every render path to 48 columns and 100 rows. Hook notifications preserve component and embedded-view routing; mention tokens remain distinct when names repeat, and composition-confirming Enter does not submit. CollabSelection skips script/style text and falls back when highlight styles cannot be installed. The scripts register inwindow.DjustHooksand only when neither hook registry already holds that name, so an app's own hook is never replaced. Components from the issue still without a script: the five of batch 5, and the optionalresize_eventof ResizablePanel andtoggle_eventof FileTree.ImageCropper now ships its interaction, and a validating cropper for the server (#2985, batch 5). The component rendered a
dj-hookthat no shipped script answered.components/static/djust_components/image-cropper.jskeeps a crop box in the image's natural pixels and lets the reader move it (drag, or the arrow keys: Shift for ten times the step), resize it (eight handles, or Alt with the arrows), draw a new one on the image, lock its shape (aspect_ratio"16/9", "4:3" or "1.5") and keep it abovemin_width/min_height(an image smaller than that is selected whole). It follows the image through window resizes, resets when the server shows another image, uses pointer events with capture (touch drags do not scroll the page), announces every change and the sent box in image pixels, and sends only{x, y, width, height}as whole numbers tocrop_event: the image is never uploaded and no pixel is read. Newdjust.components.cropping:crop_boxvalidates the numbers (the browser's claim: non-numbers, bools,NaN/infinity, huge ints, non-positive sizes and boxes outside the image are refused, a box that sticks out is clamped, fractions rounded) andcrop_image(Pillow, imported on demand) refuses an image overmax_pixelsfrom its header before decoding, applies the EXIF orientation first (the browser shows the image upright; a box taken from the screen applied to the raw pixels crops another region), strips metadata and keeps the format. The overlay that dimmed the inside of the box is hidden (the selection's own shadow dims the outside). Markup changes are additive only (the selection becomes a labelled, focusable group with eight handles, a status region, and a newaltkwarg that defaults to the old text), on the component class, template tag and Rust handler alike; a byte comparison of 7,260 renders against the previous release is identical once those are removed. The script registers inwindow.DjustHooksand only when neither hook registry already holds that name. 1 component from the issue remains.ImageUploadPreview now ships its interaction on djust's upload pipeline (#2985, batch 5). The component rendered a
dj-hookthat no shipped script answered and never sent a file anywhere. With the newuploadkwarg (theallow_upload()slot name) its input carriesdj-uploadand its drop zonedj-upload-drop, so chunking, progress events, cancellation and every server-side check areUploadMixin's;components/static/djust_components/image-upload-preview.jsadds a thumbnail per chosen or dropped image from a browser object URL (revoked on remove, replace, when the server's own previews take over, and on teardown), a progress bar fed bydjust:upload:progresswith Cancel (window.djust.uploads.cancelUpload), Remove, UX-only pre-checks for type (accept), size (newmax_size) and count (max, the server's previews included) that keep a refused file from being sent and say why, a refused-by-the-server state, drag and drop that becomes the same change event the picker produces, and polite announcements. The file input is now visually hidden rather thandisplay: none, so the picker is reachable by keyboard. Theeventkwarg now means something: when the app names one it is sent once a selection has finished uploading, with{count}(untrusted). Markup changes are additive only (data-upload,data-max-size,data-notify,dj-upload,dj-upload-dropand two empty elements the hook fills), on the component class, template tag and Rust handler alike; a byte comparison of 7,683 renders against the previous release is identical once those are removed. The script registers inwindow.DjustHooksand only when neither hook registry already holds that name. 3 components from the issue remain.MapPicker now ships its interaction and a vendored Leaflet (#2985, batch 5). The component rendered a
dj-hookthat no shipped script answered.components/static/djust_components/map-picker.jsnow builds the map, puts a marker where the reader clicks, taps or (with the arrow keys and Enter) chooses, and sends{lat, lng}(finite numbers, latitude clamped, longitude wrapped, six decimals; the handler must still validate them) topick_event. Leaflet 1.9.4 (BSD-2-Clause) is vendored under ADR-040 (leafletin the componentsdjust_assets.json, SBOM entry, license file, Subresource Integrity), loaded on demand once per page, and its marker images are passed as explicit URLs so hashed static names work. Tiles and attribution are kwargs (tile_url,attribution,attribution_url,max_zoom, pluslabelfor the accessible name): the default is OpenStreetMap's standard tile layer with its attribution, which is for light use only (production apps should set their own tile server; viewers' IP addresses reach the tile provider);tile_url=""makes no tile request; no API key is stored or shipped. The attribution is built as text, a map whose tiles cannot load still picks and says so, the keyboard moves the marker and announces every move, and the server stays in charge of position and zoom. Markup changes are additive only (newdata-attributes and an optionalaria-label), on the component class, template tag and Rust handler alike; a byte comparison of 9,075 renders against the previous release is identical once those attributes are removed. The script registers inwindow.DjustHooksand only when neither hook registry already holds that name. 4 components from the issue remain.SignaturePad now ships its interaction, and a safe decoder for what it sends (#2985, batch 5). The component rendered a
dj-hookthat no shipped script answered.components/static/djust_components/signature-pad.jsdraws on the native canvas with pointer events (mouse, touch and pen with pressure; the page does not scroll while drawing), keeps the strokes as points so Clear, Undo (last stroke) and a resize or pixel-ratio change redraw faithfully, renders sharply on high-DPI screens, follows its container's width, and exports the PNG at the pad's own size (at most twice, never the screen's). Save with nothing drawn sends nothing and says so; otherwise{signature: "data:image/png;base64,..."}goes tosave_event(and the hidden form field), never longer than the newmax_bytes(default 200 KB): the signature is re-rendered at a lower resolution until it fits and refused when it cannot, and when the server answers "Message too large" (the default WebSocket limit is 64 KiB) it is sent once more at a size that fits. "Type instead" is the keyboard alternative: a text field whose name is drawn in a script font (typed=Falseturns it off). Newdjust.components.signature.decode_signature_data_urlvalidates the value without decoding pixels (exact prefix, strict base64, size caps checked before decoding, PNG magic bytes, every chunk and CRC, dimensions and pixel count within limits, an image stream that inflates to exactly what its header promises) and returns the original bytes with their size; the handler must use it because the value is the browser's claim. Markup changes are additive only (data-max-bytes,role/aria-labelon the canvas, the typed field, Undo and mode buttons, a status region), on the component class, template tag and Rust handler alike; a byte comparison of 7,542 renders against the previous release is identical once those are removed. The script registers inwindow.DjustHooksand only when neither hook registry already holds that name. 2 components from the issue remain.VoiceInput now implements its shipped hook using browser speech recognition, with a visible vendor-service disclosure, explicit user activation, bounded listening sessions, final-transcript events, accessible feedback, unsupported-browser handling, and cleanup on focus loss/navigation/removal. Transcript cleaning preserves Unicode characters and removes invalid surrogates; notifications retain component and embedded-view routing. Existing custom VoiceInput hooks keep precedence.
ResizablePanel(resize_event=...)andFileTree(toggle_event=...)send what the reader did to the server (#2985). Both are optional and additive. Withresize_event, when a drag or key press on a ResizablePanel's handle ends the hook sends that event with{"size": <whole pixels>}(the panel's owndj-resizeCustomEvent still fires); render the remembered size back asinitial_size. Withtoggle_event, expanding or collapsing a FileTree folder (by click or keyboard, never by a server re-render) sends it with{"path": ["src", "utils"], "expanded": true}, the folder's name and its parents' names from the root down, so the server can remember which folders a user opened and render them withexpanded; the page follows the server again once the two agree. Both payloads come from the browser and are untrusted: the docstrings show handlers that clamp the size and look the path up in the rendered tree. The only markup change is adata-resize-event/data-toggle-eventattribute on the component's root when the kwarg is set: a comparison of 864 renders (hostile text, junk data, boundary values, all three render paths) against the previous release is identical without it.Resize keyboard notifications settle for 250 ms; bounded outstanding echoes preserve newer gestures and the reset target, while unrelated server changes take over. No-op resize/toggle requests do not notify. Both optional hooks preserve component and embedded-view routing, and the FileTree example validates boolean expansion state.
Terminaltakesauto_scroll(#2985).auto_scroll=Falsemakes a streaming Terminal leave the scroll position alone instead of following the newest line (the default,True, is the behaviour before). The only markup change is an addeddata-auto-scroll="false"when it is off: the default markup is byte-identical, on the component class, the template tag and the Rust handler alike. LogViewer already had the option.SortableList, SortableGrid, JsonViewer and LogViewer now ship the interactions their docstrings describe (#2985). Each rendered a
dj-hookthat no shipped script answered;components/static/djust_components/sortable-list.js,sortable-grid.js,json-viewer.jsandlog-viewer.jsnow do, and the catalogue page of each includes its script. Sortable lists and grids reorder by mouse drag or keyboard (Space grabs, arrows move, Enter drops, Escape cancels, each step announced in a live region) and send{order: [...]}throughmove_event; their items gain adata-keyso the server's re-render moves the nodes the client already moved (a list whose ids are empty, missing or repeated cannot be keyed, so it stays inert and logs one console warning). JsonViewer expands and collapses on click, Enter, Space and the arrow keys, and its Copy button copies the formatted JSON. LogViewer follows the newest line until the reader scrolls up, appends lines pushed tostream_event, and honoursmax_lines,show_line_numbersandfilter_levelthrough newdata-*attributes. The scripts register inwindow.DjustHooksand only when neither hook registry already holds that name, so an app's own hook is never replaced. The other 16 components from the issue are unchanged.@push_handler: an explicit marker for a method only server push may call (#3002).push_to_view(..., handler="name")reaches a marked method under any name, and a browser event naming it is refused in everyevent_securitymode (strict,warnandopen) on the WebSocket, SSE, HTTP POST and HTTP API transports, with the same response as a method that does not exist. The marker is inherited by method name, so an unmarked override of a marked method stays push-only; the new system checkdjust.V021reports such an override (Info), or one that adds@event_handler(Warning). It is not an event handler:djust.V004no longer suggests@event_handlerfor it,djust.T019reports a template binding to it in every mode, andLiveViewTestClient.send_eventrefuses it while the newsend_pushhelper delivers it. It cannot be combined with@event_handler,@server_function, or the decorators server push does not enforce (@permission_required,@rate_limit,@debounce,@throttle,@cache,@optimistic,@client_state): each is aTypeError. Existinghandle_*push targets and@event_handlermethods are unchanged. 41 regression cases inpython/djust/tests/test_push_handler_marker_3002.py.Add experimental, opt-in
djust.clocksprocess-localRoomClockandSharedPoll: one step per tenant-scoped room, presence-bound start/idle stop and restoration, bounded late-beat handling, shared worker pool, error backoff, scoped doorbells, stats, variable intervals and pause/resume. Distributed ownership/fencing remains deferred (ADR-042, #3005).connected()anddisconnected()are real LiveView hooks (#3007).connected()runs once per live (WebSocket or SSE) mount, aftermount()(or a state restore) andhandle_params()and before the first render, so state it sets is in the first frame; raising fails the mount likemount()does.disconnected()runs once for each view that reachedconnected(), when its live mount ends: the socket or stream closes, or alive_redirect, a secondmountframe, anunmountframe or a revoked authorization releases the view. Both are regular methods (notasync def) and run on a worker thread (the ORM works),disconnected()under the tenant the view mounted with, and an exception indisconnected()is logged without stopping the teardown. The HTTP render and the HTTP POST fallback never call them,disconnected()is not called if the process dies, and views a template embeds with{% live_render %}do not get them. A member that already carries either name but is not a no-argument regular method (anasync def, a method that needs arguments, a property, a state attribute) is skipped with one warning per class and never fails a mount; a regular no-argument method of that name is now called. There is no ordering between the connections of one user: a reconnect can runconnected()of the new view beforedisconnected()of the old one, so key claims by connection. Both are listed indjust.schema.LIFECYCLE_METHODSagain, and the LiveView API reference states the lifecycle contract, including what is not guaranteed. 25 regression cases inpython/djust/tests/test_lifecycle_contract_3007.py.Lazy and batched views are independently live over SSE, and views of one class keep their own saved state (#3252). Over WebSocket each view a
mountnames with atarget_idwas already a view of its own; an SSE session hosted one view, so adj-lazycontainer never hydrated over it and an event from inside one either did nothing or, throughsendEvent's fourth argument being akeepaliveflag, could lose its address. An SSE session now hosts the page view plus views mounted beside it: amountPOST that names atarget_idadds one with its own runtime, state, saves, authorization and teardown, its frames carry thetarget_id,unmountreleases one, a navigation and the end of the stream release them all, a frame for an address with nothing mounted is refused withcode: "view_unavailable", mount and unmount frames count against the session's rate limit (rate_limited), and the client mounts them again after a reconnect. Adj-hookpushEventnow works over SSE (it used to need the WebSocket), addressed to the view it sits in. Without a stream (a browser with noEventSource) a lazy view's hydration reportsview_unavailableinstead of leaving a container that looks live. State isolation, on every transport: an explicit view'spersist="server"envelope is stored under the view's own address (two views of one class used to restore whichever saved last; the page view's key is unchanged), and a view beside the page view mints, refreshes and revokes no navigation snapshot, so it can no longer replace or revoke the page view's. Still open on #3252: lazy views over the page-POST fallback, a non-sticky child's state between HTTP requests, events sent from code with no element, hydrating the destination's lazy containers afterlive_redirect, a shared render lock, and a refused login-required lazy view redirecting the whole page. Covered inpython/djust/tests/test_sse_view_slots_3252.py,python/djust/tests/test_multi_view_state_isolation_3252.py,tests/js/sse_view_slots_3252.test.jsandtests/playwright/test_multi_view_sse_http.py.AI discovery INFO checks (#3448). Add
djust.I001agent discovery hints,djust.I002for LiveView projects without theming, anddjust.I003for fresh cached X101–X104 UI findings even when components are enabled. Missing/stale caches are silent.Behavior change: Agent-run
manage.py check --fail-level INFOnow fails unless I001 is silenced; unsilenced I002/I003 can also fail this threshold. I002 also prints on runserver/migrate for LiveView projects withoutdjust.theming. UseSILENCED_SYSTEM_CHECKSorDJUST_CONFIG["suppress_checks"]per ID;DJUST_AI_HINTS=0silences I001 only.AI capability discovery (#3448). Add
djust_ai inventory,suggest, andmanifest, exposed throughdjust ai, plus MCPlist_ui_componentsandget_ui_component. The shared catalog supplies component purposes, keywords, props, and examples. Suggest/manifest run in-process; inventory uses configured settings or a validated nearbymanage.pyentry point.UI component audit rules X101–X105 under
djust_audit --ast(#3448). Warn on hand-built looping tables (data_table,data_grid,infinite_scroll), long or generated reactive selects (combobox,rich_select), overlay panels (sheet,modal), class-switched messages (server_toast_container,toast_container,page_alert), and linked CSS color literals outside theme tokens (theme tokens or a preset). Template findings support same-line{# djust: noqa X101 #}comments; CSS uses/* djust: noqa X105 */.--ast-no-uiopts out of UI rules, while--ast-no-ui-cachedisables the default atomic UI summary cache at .djust/audit-ui.json. The writer creates a self-ignoring .djust/.gitignore only when it creates .djust/; write failures do not fail the audit. 101 regression test functions inpython/tests/test_audit_ui_3448.py(170 collected cases including parametrization).Behavior change: Projects running
djust_audit --ast --strictin CI will now fail on X1xx warnings. Migrate by adding--ast-no-ui, or fix findings / add specific same-linenoqacomments. Disabling cache writes does not disable the warnings.
Changed
djust initreports the documentedTEMPLATESshape, anddjust init --templatesadds it (#2884).djust initnever editsTEMPLATES: it prints an informational step and the entry that putsDjustTemplateBackendfirst (with"NAME": "djust", matching the installation guide;djust newwrites the same entry without aNAME, so its engine alias staystemplate_backend). With--templatesit appends a marked block to aTEMPLATESit can read with certainty: the djust entry mirrors the firstDjangoTemplatesentry'sDIRSand supportedOPTIONS, and the project's own entries stay after it unchanged. ATEMPLATESthat already lists the djust backend is left alone, and oneinitcannot read is ATTENTION only when--templateswas passed. Removing the block is the undo: a rerun without the flag never adds it back. The installation guide now lists what djust's engine renders differently from Django's (these differences predate this change), and how to find theDjangoTemplatesentry once djust is first. 32 regression cases inpython/djust/tests/test_djust_init_templates_2884.py.- Built-in preset labels that failed WCAG AA on their fill now meet it; no fill, background or border token moves (#2885). For 57 presets, 374
*_foreground,linkandlink_hovertokens move in lightness only (hue and saturation kept) to the nearest lightness that reads at 4.5:1 on their fill and does not read worse where else the label is painted: label text on solid fills flips between light and dark ink for the 337 of them that cross sides (white on a bright button becomes dark ink), and the other 37 are same-side moves. A solid button's hover fill (the fill at 75-90% alpha over the page or a card) and the--codesurface of syntax comments are guarded: a move that cannot keep them is held (tailwind and everforest lightmuted_foreground). A link and its hover move together and keep their order. Matrix failures drop from 540 to 158 and 382 exemptions go. The generated CSS of all 68 presets differs only in the label and link variables, never in a fill, border or dot variable.--muted-foregroundis also drawn as dots, switch tracks, scrollbar thumbs and spinners; its 8 larger moves make those more contrasting against the page, card and muted surfaces (no painted use gets weaker). Rules that read a label on a surface the contrast matrix does not measure were fixed: the tooltip bubble pairs--backgroundwith its--foregroundfill, the rich-select status variants read the derived*-textcolours of their wash, and the data-card filter chip no longer swaps--accentunder its active label on hover. A test scans every rule that setscolorfrom a*-foregroundtoken (and every state rule of its element family) and requires the background to be that fill. Exempt on purpose, with their reasons recorded: the tokens whose source documents a brand hex (monokai and stripelink, github and stripe muted text), which keep that hex, the two held code-surface tokens, and the 131inputborders (non-text UI components; 3:1 not applied to the shipped presets, revisit on request; no border changes). Still open:accent_foreground, which.status-badge-accentpaints as a background.scripts/fix_theme_text_contrast.py --include-substantialapplies and checks this. theme_contextrenders its HTML lazily; its settings values stay eager (#3028). The processor used to rendertheme_head,theme_panel,theme_mode_toggle,theme_preset_selectorandtheme_switcheron every request that built aRequestContext, including JSON views, redirects, admin and pages that never print them (about 23 ms per request with a large pack). Each is now a lazy trusted-HTML object that renders on the first read, once, for the request it was built for. The text a template gets is unchanged (byte-identical across every preset and mode on the Django engine, the Rust renderer and the HTTP page shell), andtheme_preset,theme_mode,theme_resolved_mode,theme_pack,theme_presetsandcomponents_gallery_urlstay plain, eagerly computed values. Nothing is shared across requests, so a CSP nonce or a user's preset never leaks. The five HTML variables are no longerstrinstances;{{ }},|safe,|length,{% if %}and==against a string still work, andstr(...)gives the text. Python that needs a realstr("".join,re,startswith,os.fspath, plainjson.dumps) raisesTypeErroron one; the guide lists the cases. On the LiveView (Rust) renderer a chunk reaches the template as its rendered string, soin,==,|add,|sliceand|defaultsee the same text as before, and a LiveView session whose Rust state was stored by an earlier build (which kept the renderedtheme_headin that state) drops the stale entry on the next sync instead of rendering it escaped. A page that never readstheme_headno longer makes django-csp mint a nonce for the response.djust_theming.E001stays a Warning, and its docs entry no longer cites the per-request cost. Regression cases inpython/djust/tests/test_theming_lazy_context_3028.py.- A refused lazy or batched view fails alone (#3252). A view mounted beside the page view that the user may not see (login required, a missing permission or object permission, an
on_mountredirect) answered with the page-levelnavigateframe, so one refuseddj-lazyview sent the whole page to the login page. It now answers with aview_refusedframe addressed to its container (overmount_batch, an entry of the reply'srefused[]array, and nonavigate[]entry): the client replaces that container's content with a shortrole="alert"message and, for a login or redirect, a link to the sign-in URL, setsdata-djust-refused="<reason>"on it, forgets the view and firesdjust:view-refusedso an application can show its own prompt. The page's URL, the page view and the other views are untouched, on WebSocket and SSE; the refused view is torn down on its own, and a refusal at event time ends it the same way: a re-check that fails (reauth_on_event) refuses that view in its container (it used to send the whole page to the login page and close the socket; the page view's own failure still does), and an event for a refused or unmounted address over WebSocket now answerscode: "view_unavailable"like SSE. The page view's own refusal is unchanged. Upgrade note: a client older than this release does not knowview_refusedand leaves a refused lazy view's container empty instead of navigating.docs/website/guides/multiple-views.mddocuments the frame, and states what stays as it is: lazy views reload fresh on Back and Forward, lazy views without any live transport are refused withview_unavailable, and a page of lazy views only needs a minimal page view over SSE. Covered inpython/djust/tests/test_multi_view_refusal_3252.py,tests/js/view_slots_3252.test.js,tests/js/sse_view_slots_3252.test.jsandtests/playwright/test_multi_view_refusal.py. - Views of one connection have their own render locks and turn lanes; over SSE a slow handler in one view no longer delays the others, over WebSocket only
async defhandlers overlap (#3252). The views of a connection (the page view and thedj-lazy/mount_batchviews beside it) shared one render lock and one receive loop. Each view now has its own render lock, deferred-push queue and "event in progress" flag, and while a connection hosts several views its inbound frames (event,url_change, the view-addressed verbs such asrequest_htmlandupload_register, and the binary frames of an upload, which follow their register) run on a lane per view (python/djust/_view_lanes.py): one view's turns run in order, one at a time, beside other views' turns. Over SSE each POST takes its own view's lock and runs on its own thread, so views overlap whichever way their handlers are declared. Over a WebSocket the limit is the connection's single synchronous thread: handlers declared with plaindef, and the connection-cleanup step every inbound frame makes before dispatch, run on it, so while adefhandler blocks it no other view's frame is answered (two strict expected failures pin this);async defhandlers,start_asyncwork and awaits overlap. The isolation starts once a second view is mounted (a one-view page runs its frames inline, exactly as before; measured 1.6 ms per event before and after). Tearing a view down (unmount, mounting its container again, a navigation, the disconnect, the stream close) waits for the running turns of the views it releases; the frames of an upload follow their register on the view's lane (known refs only, bounded atrate_limit.upload_burstxmax_message_sizebytes, about 25 MiB; past it the upload is stopped with anupload_progresserror); a view with 64 turns already waiting refuses further frames withcode: "view_busy"(withref,target_idandevent; not retried). 33 regression cases inpython/djust/tests/test_multi_view_render_locks_3252.py(two are strict expected failures). - The Python 3.15 CI cell can install its dependencies and pass its tests (#3255). All four
py3.15shards failed inuv syncbefore any test ran: fourteen locked packages had nocp315wheel and were built from source, and the Rust ones (rpds-py,watchfiles) were refused by their old PyO3 ("newer than PyO3's maximum supported version (3.14)").uv.locknow carries versions withcp315wheels (cffi,librt,msgpack,orjson,rpds-py,ujson,uvloop,watchfiles), and the dev extra pinspydantic>=2.14.0b2for Python 3.15 only, becausepydantic-corehas nocp315wheel before the 2.48 series that only pydantic 2.14 (still a beta) pulls in. Python 3.11 to 3.14 keep their existingpydantic.autobahn,httptools,pyyaml,zope-interface,zstandardand, on macOS,watchdogstill build from source on 3.15 (C extensions, a few seconds each). The Rust extension itself builds on 3.15 with PyO3 0.29.2 unchanged. Thepy3.15cell stays allow-failure until 3.15.0 is final. - Markdown output keeps at most 32 containers open at once.
djust.render_markdownand{% djust_markdown %}emit one<blockquote>per>and a<ul>/<li>pair per list level, so the depth of the output, and the time spent parsing and diffing it, grew with the nesting of the source. A block quote, list or list item that starts whileMAX_MD_NESTING(32) containers are already open is now rendered without its own tags; its content stays in place inside the innermost kept container, so no text is lost, every kept<li>stays balanced, and the cost of rendering stays proportional to the length of the source. Documents that nest 32 containers or fewer (ordinary documents: 914 distinct Markdown files from the docs of this repository and its sibling projects all render unchanged, and the deepest nests 6) render byte for byte as before. The bound is exported asdjust._rust.MAX_MD_NESTING. Regression cases inpython/tests/test_markdown_nesting_bound.pyand themarkdownunit tests ofdjust_templates. - A server push is stamped with its tenant, and a tenant view applies only its own.
push_to_view/apush_to_viewnow stamp every push with the tenant it is for (tenant=, defaulting to the tenant bound to the running context), including pushes withoutscope, and aTenantMixinview drops a push stamped for another tenant, whichever group carried it. A tenant handler that pushes view-wide now reaches its own tenant's sessions only; passtenant=None(or push from a task with no tenant) to broadcast to every tenant. Views that are not tenant-scoped, pushes with no stamp (from a process on an older djust, so rolling upgrades keep working) and database NOTIFY handling are unchanged. - Terminal's ANSI conversion keeps a bounded number of styles open per line. Each open style is a nested
<span>until a reset or the end of the line, so a short line could nest as deep as it was long. The conversion (in theTerminalcomponent, the inline handler and the{% terminal %}tag) now keeps at most 32 styles open per line and opens styles from at most the first 16 parameters of a sequence (the client hook reads the same 16); a style past either cap is dropped, and a reset anywhere in a sequence closes every open style. Lines that stay under the caps, which is every line a program prints in practice, render byte for byte as before. Regression cases inpython/tests/test_terminal_ansi_span_cap.py. - The VDOM bounds the nesting depth of the HTML it parses. Every walker over the VDOM (diff, patch serialization, serialization back to HTML) recurses once per nesting level, so a page whose markup nests thousands of elements deep needs more stack than the rendering thread has. The parser now keeps at most
MAX_VDOM_DEPTH(256) levels, counted from the LiveView root: an element at the limit keeps its tag and attributes, and everything below it is flattened to its text content, so nothing nests deeper and no text is lost (except below an SVG or MathML<style>,<script>or other raw-text-named element at the limit, which keeps no text: its descendants' text is not its own). Pages are unaffected: the deepest of 92 rendered demo-project pages and 316 template files nests 14 levels, and parsing, diffing and serializing them gives byte-identical results before and after. The search for thedj-rootelement no longer walks the raw tree recursively either. The limit is exported asdjust._rust.MAX_VDOM_DEPTH; the cached-subtree fast paths (a re-rendered component, a loop item) fall back to a full parse when grafting a subtree would pass it. Regression cases inpython/tests/test_vdom_nesting_bound.pyandcrates/djust_vdom/tests/test_nesting_depth_bound.rs.
Fixed
- Text painted with
primary,brand,info,successorwarningnow reads the derived*-textcolour (#2885)..text-primary,.text-success,.text-warning,.text-info,.btn-link, links, alert, toast, badge and status text and the alert/toast icons usevar(--primary-text, var(--primary))and its siblings; backgrounds, borders and the status dots keep the fill (-dangerincluded: that dot is restored to its pre-#3320 fill) (anything drawn fromcurrentColoron a text element, such as an underline or an icon stroke, follows its text colour). The 347primary/info/success/warningas-text pairs that were exempt inA11Y_EXEMPTIONSare now measured through the derived colours (the contrast matrix anddjust_theming.W001) and pass with no exemption. Override the-textcolour next to a fill you override while the theme generator is active. - Built-in preset text colours that missed WCAG AA by a small step now meet it (#2885). 139
*_foreground,linkandlink_hovertokens across 55 presets move in lightness only, hue and saturation kept, by at most 15 points, never across the light/dark polarity of their surface, and with a 0.05 margin over the minimum; alinkand its hover move together and the hover keeps its side of the link. That clears 172 documented failures fromA11Y_EXEMPTIONSand 22 failing hover pairs. No fill, background or border token changes, but the CSS also paints--muted-foregroundas dots, switch tracks and scrollbars, which shift by the same step.scripts/fix_theme_text_contrast.pyproposes, applies and checks these moves; polarity flips, larger moves, tokens whose source documents an exact hex, the status and primary colours read as text, and the input border are left for review. 24 regression cases inpython/djust/tests/test_theming_text_contrast_2885.py. - A Python-bridged tag inside an overriding
{% block %}rendered the parent for every call it made, whether or not it readblock.super(#2918). Output matched Django's, but side effects in the parent (a counter, a cache warmer, a bridged tag) ran several times as often: 60 handler calls against Django's 12 in the #2916 probe. Theblocka bridged tag receives is now a lazy object whosesuper()renders the parent when it is read, and renders it again on every read (no memoization, as Django'sBlockNode.super()is a method call), so the parent's tags run exactly as often as in Django's engine and the parent's own exception class reaches a tag'sexceptand a{% blocktranslate with s=block.super %}unchanged. It has Django'sBlockNodeshape:context["block"].super(),"super" in block(never renders). The older dict-style reads keep working as a deprecated path (see the Deprecated entry). The object is only valid for the bridged call it was handed to, so a tag that keeps the context it receives no longer forms an uncollectable cycle. A user variable namedblockis no longer replaced. 30 regression cases inpython/tests/test_block_super_bridge_lazy_2918.py, one of them a 57-template matrix compared against Django's own engine on rendered output and per-tag call counts. - Keep Escape confined to ImageLightbox and Tour so enclosing keyboard bindings and window shortcuts do not act on the same key. Wrapping Tab still uses preventDefault and may propagate (#2985).
- LogViewer stopped following a stream that trims old lines, and never resumed (#2985). With
max_linesset, trimming shrinks the content and the browser may clamp the scroll position; the scroll event for that clamp arrived after more lines had been appended and was read as the reader scrolling up, so from the third burst on the log ended a few pixels short of the newest line. The hook now only treats the position moving up as the reader scrolling when wheel, touch, key or pointer input on the log came just before it, judged by the events' own timestamps so a busy page between the two does not separate them (or a pointer is held, as in a scrollbar drag), so a clamp, whenever its layout happens, never stops the follow, and a reader who scrolls up during a stream that trims every frame stays up. As a consequence, code that sets the log'sscrollTopitself no longer stops the follow. The new Terminal hook has the same follow logic and the same fix.auto_scroll=Falseon a LogViewer turns the follow off altogether. - MapPicker notifications preserve nested component and embedded-view routing, including strict-binding root context (#2985).
- SignaturePad notifications preserve nested component and embedded-view routing. A refused export reports that it cannot fit without mislabeling the rejected frame size as the server limit; the browser regression verifies both a retryable export and a bounded refusal (#2985).
- JsonViewer string, number and boolean values were near-black on its dark background (#2985). The class-coverage rule
.dj-json__valueset the page foreground and out-ranked the colour rules for each value type; those rules now carry a second class, so values show in their syntax colours (6.3:1 for strings against the default background). - The Python render and the Rust VDOM now agree on which element is the LiveView root (#3031). One rule on all three locators: an embedded
{% live_render %}child's wrapper and everything in it belong to the child and are never the parent's root; of the rest, the firstdj-rootwins, else the firstdj-view. Python extracts a view's root from its template and hands Rust only that element, so a page that has a root renders and patches exactly as before. Three things change. A page with no root of its own that embeds a child no longer gets a VDOM rooted at the child's wrapper; it falls back to the first element in<body>, as any rootless page does (the page is not live either way). Python now recognises an embedded wrapper written with upper-caseDATA-DJUST-EMBEDDED. And raw input given straight to the Rust VDOM or its text scanner, such as<nav dj-view>before<main dj-root>, now picks<main>, as Python does. - The daily Retro Gate Audit can no longer confuse "found something" with "crashed", or "clean" with "could not read" (#3129). Two defects, one on each side of the workflow/script boundary. The workflow ran
scripts/audit-pipeline-bypass.pyunder GitHub Actions' defaultbash -e, so it aborted at the script's exit 1 — its normal "found something" signal — before capturing the exit code, echoing the output, or emitting any annotation (set +o pipefaildoes nothing about-e); the observed result (run 37672728078) was a red cron with the findings thrown away, and becausesteps.audit.outputs.flaggedwas never written the summary fell through to itselsebranch and printed "✅ No flagged PRs." for an audit that never reported. Underneath, the exit code never carried the meaning the workflow assumed: an uncaught exception also exits 1, and a failedghread was swallowed into an empty result — so an authentication failure produced a clean audit while an unparseable-JSON crash produced phantom findings. The script's exit status is now an explicit contract —0clean,1findings,2could not complete — with a finalAUDIT_VERDICT: clean|findings|incompleteline as the machine-readable verdict, and a failedgh/gitread raisesAuditIncompleteinstead of returning empty. The workflow dispatches on that verdict:cleanstays green,findingsstays green with one::warning::per flagged row, andincomplete(or no verdict at all) emits::error::and fails the step; the captured output is echoed to the run log and uploaded as an artifact, and the summary reports "no verdict" rather than clean when there is none. 10 regression cases intests/test_retro_gate_audit_workflow_3129.py, including the real script driven through stubgh/gitreads for the failed-read and crash paths, and a forgedAUDIT_VERDICT:line in audit content that must not flip the verdict. - A lazily-rendered slot is now filled over the page-POST fallback, instead of being left empty (#3252).
{% live_render ... lazy=True %}renders a<dj-lazy-slot>placeholder and stashes a thunk whose fill envelope is transport-agnostic — an inert<template id="djl-fill-X">plus an activator, which50-lazy-fill.jsapplies (itsDOMContentLoadedauto-scan already covers the activator running before the bundle defineslazyFill). The WebSocket consumer and the SSE chunk emitter both ran those thunks; the plain page render did not, so a browser with neither transport got an empty slot even though the server could render it. The page render now runs them and appends the envelopes. Nothing else changes: the placeholder is still emitted and the client still performs the replacement, thevisibletrigger still defers until the slot is on screen, and the stashed thunks are cleared either way so a re-used view instance cannot leak them into the next response. Thedj-lazyattribute form (<div dj-view=… dj-lazy>) is a different mechanism with no server-side registration and is unchanged — it is still refused over that transport. It uses one authoritative pass — the page render, not the VDOM-diff baseline that renders the template a second time — so a slot under an explicit-exposure page, whose child ids differ between the two passes, still fills exactly once. 4 regression cases inpython/tests/test_lazy_slot_http_fallback_3252.py. - Attribute-form lazy views (
<div dj-view="app.Child" dj-lazy>) now fill over the page-POST HTTP fallback and keep their own state across events (#3252), as{% live_render lazy=True %}already did. A container registers only when the renderer records that its tag,dj-viewanddj-lazybytes came from authored template source and the final page parses them as a live element; containers inside loops, conditionals, includes and inheritance, and with unrelated interpolated attributes, register. Escaped text, comments, script bodies and markup re-injected through|safenever do. - Bind lazy-view authority to the exact authored start tag and attribute bytes; preserve conditional child identity, keep ordinary pages on plain rendering, use linear tracked normalization, and validate final HTTP pages once. Wrapper-template pages no longer issue unusable lazy addresses; browser smoke logs use temporary storage.
- A
Decimalrefusal message now spells the type name the way the running interpreter does (#3255). djust reproduces CPython's'X' object is not iterablewording, and it carrieddecimal.Decimalas a literal. That is the C_decimalaccelerator's spelling — a static type, so itstp_namecarries the module. An interpreter without_decimalfalls back to the pure-Python_pydecimal, a heap type whosetp_nameis the bareDecimal, so{% for x in a_decimal %}said'decimal.Decimal' …where Django — and CPython — said'Decimal' …. The name is now measured once per interpreter, alongside the classis_decimalalready resolves, so djust matches Django on both. Where no interpreter is initialized at all — the pure-Rust renderer path, where the measurement is impossible and previously unnecessary — it falls back to the C spelling rather than requiring one. This is what made the informational Python 3.15 CI shards red (its build has no C_decimal) while passing on every other interpreter; the two tests that listed the spelling now ask CPython for it (tests/cpython_scalar_names.py) instead of hardcoding it. @background, the async-work runner andtrack_performanceno longer call the deprecatedasyncio.iscoroutinefunction(#3255). CPython 3.14 deprecated it and 3.16 removes it, so on 3.14 and 3.15 each call warned and on 3.16 each would have raised. They useasgiref.sync.iscoroutinefunction(already a Django dependency), which gives the same answer for anasync defand for a sync wrapper marked withmarkcoroutinefunctionon every supported Python.python/djust/tests/test_asyncio_iscoroutinefunction_3255.pyscans the package for the old spelling and runs each decorator withDeprecationWarningas an error.- A
dj-viewordj-rooton<html>or<head>is ignored, and the page stays a plain HTTP page (#3302). It is unsupported (the VDOM starts at<body>), and before it mounted a view that replaced the document element's content: the body shrank to its first element and the client threw a TypeError. The server render is unchanged; the client now treats<html>and<head>as never a container (no mount over WebSocket or SSE, nothing stamped), and logs one console warning that names the fix.djust.T025keeps flagging them, and its message now says the attribute is unsupported and ignored and to put it on<body>or a<div>; the runtime warning says the same. A browser still running an older cached client script would mount such a page as before. - Ignore literal root markup inside textarea RCDATA when selecting, stamping, or balancing a LiveView root in Python and the native text scanner. This corrects the documented
lookalike_in_textareacorpus divergence to browser semantics and lets body roots mount over WebSocket/SSE. Retained body scripts are not rerun or marked executed based on DOM presence; DEBUG reports their execution status as unknown, since parsed scripts may have run while innerHTML-created scripts remain inert. <body>is a supported root (#3302). A<body>that carriesdj-rootordj-viewis the view's root, on the Python render and the Rust VDOM alike, when nothing inside<body>declares a root; its children (a<header>, a<main>, a<footer>, ...) are the page's top-level content. Before, such a page connected and then replaced<body>with its first element. A page that already has a root keeps it: adj-rootordj-viewon an inner element still wins over<body>, a wrapper element keeps working as before, and the wire format is unchanged (on 40,000 generated pages, none that had a root the Python and Rust sides agreed on changed). Adj-lazyview's container now counts as another view's, like an embedded child's wrapper, so it never becomes the page's root. The nodes djust did not render for the view (its injected scripts and debug panel, dev toolbars, browser-extension nodes) stay in place when the client mounts, navigates or applies a full-HTML update, and the page's own scripts are not run a second time.<body dj-root>gets its view path stamped when it is the root.djust.T025no longer flags<body>(it still flags<html>and<head>, which are not supported). The client also keeps thedata-djust-targetaddress of adj-viewcontainer when a page mount morphs it, sodj-lazyviews inside a root mount.- Draft mode saved and restored only the fields present at page load (#3351). With
DraftModeMixin, adata-draftfield that a patch inserted (a conditional{% if %}block, a wizard step) saved nothing, a field replaced by a new element stopped saving, and a draft root inside adj-lazyview was never wired, so text typed there was lost on reload. Saving is now one delegated listener installed once, and a field is restored once when it first appears, never over a field the user is typing in or has edited. Each draft root saves the fields inside it under its own key. A save is merged over the stored draft, so a field that is not on the page at the moment (an earlier wizard step) keeps its saved value untilclear_draft()removes the draft (nothing else does, logging out included); two roots sharing adata-draft-keyoverwrite each other. The saved draft wins on a field's first appearance and is never put back later, nor by a reconnect's mount or a lazy view's re-mount. Separately, the page-load restore of a static field was undone by the mount morph of the prerendered page, which wrote the server's value back; the draft is now restored after that morph. Covered by the vitest suitetests/js/draft-mode-after-init-3351.test.jsand a real-browser script intests/playwright/. djust_ai_contextleft eleven directives out of the generated CLAUDE.md,.cursorrulesand Copilot files (#3353)._section_directiveswalked a hand-kept category list that omittedperformance,animationandrecovery, sodj-prefetch,dj-key,dj-lazy,dj-virtual,dj-track-static,dj-transition,dj-remove,dj-transition-group,dj-view-transitions,dj-flipanddj-auto-recoverwere only visible throughdjust_schemaand the MCP server. It now emits every category present in the schema (the existing ones unchanged and in their old order, any other alphabetically after them, two lines per directive), about 40 extra lines in the generated file. 10 regression cases inpython/djust/tests/test_ai_context_every_directive_3353.pycheck that every directive in the schema is in all three formats.dj-modelfollow-ups (#3355). The debounce timer of a field is now keyed by the view the element lives in as well as the field name, so two lazy views binding the same debounced field no longer drop one view's update. Acontenteditableelement now sends its text asvalue(with.lazy, when it loses focus), anddj-model.debounce-Nis found on controls andcontenteditableelements. The scan that runs after every patch skips an unchanged bound element on its recorded attribute instead of walking its attributes: in Chromium a re-scan of 2,000 bound inputs among 4,000 inputs and 2,000 other elements fell from about 5.0 ms to 1.1 ms (2,000 bound inputs alone: 3.3 ms to 0.6 ms).- Presence broadcasts from async event handlers now reach peer sessions for every
PresenceMixinview, including views without a room clock (#3425). Bound joins waiting for stopping room clocks to one second, and defer async first-join callbacks until the clock ensure completes (#3426). Presence-bound joins retry after the old run retires if participants remain; clock initialization failures and untracking during a wait preserve join/leave callback ordering. Payload initialization does not consume the stopping-run wait budget. - Fixed lazy HTTP container provenance through skipped control-flow branches, isolated include-site identities across inline template inheritance, and reduced dynamic planning keys to include selector inputs.
- Malformed HTML declarations and marked sections no longer abort template binding, page-shell, recovery-target or child-slot scanning. Binding reports record incomplete scan coverage rather than treating the template as fully scanned.
- Attribute-form lazy containers are authoritative only when they are live in the rendered branch's authored text (#3430). The page's literal-only rendering (template text and template-literal expression output such as
{{ "<!--" }},cycle/firstofliterals, literalwithbindings andblock.super, with other values replaced by empty strings in B and U+FFFD in P) is parsed once per projection with html5ever. A container registers only when it is live in B, P and the final page. B replayscutnormally; P preserves opaque and authored U+FFFD separators undercut. A value can no longer complete a comment, script or attribute that the authored text left open; ordinary output (csrf_token,djust_client_config,|safemarkdown, SVG icons insidespacelessorcache) leaves containers working. Hiding a container with a value is not modelled: don't.{% cache %}fragments now carry their literal-only bytes; clear fragment caches on upgrade, as entries written before it fail closed until they expire. Captured content replays only allow-listed text transforms with literal arguments, and receives the page's final escaping decision, so removed or escaped authored closers cannot survive as stale context. Byte-changing unlisted transforms, includingslice,striptags, fallback filters and custom captures, fail closed. Each value occurrence keeps adjacent literal bytes apart, including split comment and raw-text closers; empty page values can conservatively lose registration. - A late storage failure on an explicit save no longer reaches the
asynciolog with its message and traceback on Python 3.14. The save deadline waited withwait_for(shield(save)); since 3.14,asyncio.shieldhands the exception of a shielded future whose waiter has gone to the event loop's exception handler, so a save that outran its deadline and then failed was logged a second time, with the storage exception's text, next to the runtime's own value-free warning. The deadline now waits withasyncio.wait, which leaves the save running and observes nothing, so the runtime's warning is the only report. The supported-Python test matrix is also green again on CPython 3.13.15 and 3.14.7: the pinnedstriptagsreference records thehtml.parserchange to abruptly closed empty comments (<!-->,<!--->) that those patch releases made, and the Unicode sweep of thetitlefilter pins the 52 code points that Unicode 16.0 (CPython 3.14) made cased and the filter's 15.x tables do not know. - Fix CI Python matrix interpreter selection and verify the actual runtime before building the extension.
- Components check the CSS values they write into
styleattributes. A colour, length, ratio or number that a component takes from its caller and writes into an inlinestyleattribute is now checked against the shapes the component documents, and anything else is replaced by the component's default. Colours accept#rgb/#rgba/#rrggbb/#rrggbbaa, a CSS named colour (withtransparentandcurrentcolor),rgb()/rgba()/hsl()/hsla()with plain numeric arguments, andvar(--name)references; lengths accept numbers with a unit, the usual keywords,var(), andcalc()/min()/max()/clamp(). Valid values render exactly as before. CoversCursorsOverlayandCollabSelection(the user colour),CalendarViewevent colours,Metersegment colours,ColorPicker, kanban column colours, the data table colour scale, and the heights, widths, gaps, offsets and ratios ofMapPicker,RichTextEditor,ScrollArea,StickyHeader,DataGrid,DataTable,ResizablePanel,SortableGrid,DashboardGrid,AspectRatio,ResponsiveImage,SplitPane, the progress components and{% theme_skeleton %}, on every render path (component class, template tag and Rust template engine). The helpers aredjust.components.utils.safe_css_color,safe_css_length,safe_css_ratioandsafe_css_number. - Make affected Python test selection fall back to the full suite when any changed file is unclassified, test sources are unreadable, or shared/transitive code changes; document focused edit checks and unchanged full integration/release gates.
- Text in an SVG or MathML
<style>/<script>is serialized as escaped text. The VDOM wrote the text of any element namedscript,style,noscript,xmp,iframe,noembed,noframesorplaintextverbatim, by tag name alone. The HTML serialization rules apply that only to HTML elements: inside<svg>or<math>these are foreign elements, whose text the parser decodes, so it has to be escaped again when written back (<stays<). The serializer now tracks the namespace while it writes (svgandmathswitch to foreign content;foreignObject,desc,title, MathML text elements and HTMLannotation-xmlswitch back), so re-parsing the HTML gives the same tree. HTML<style>and<script>output is byte-identical, and so is SVG CSS without<,>or&. HTML raw-text content is written verbatim exactly as the HTML serialization algorithm writes it, including a script that holds</script>inside<!-- … -->(the legacydocument.writepattern) and<plaintext>. An inserted{% if %}block (anInsertSubtreepayload) is written the way the page writes it, since the client now parses it in its parent's namespace, except that a<noscript>there always has its text escaped: the client parses the payload with scripting disabled, where<noscript>holds markup, not raw text. Therender_binary_difftext-only fast path now updates a text node only when the HTML's text runs and the tree's text nodes line up one for one, and never inside a raw-text element: it mapped changes by byte offset, so dropped whitespace or text outside the root sent a change to the neighbouring node, and it decoded<inside a<script>. The text fast paths (the in-place update,try_text_only_vdom_update,try_text_patches, and the fragment and text-region paths ofrender_with_diff) also require each text node to sit in the element the source has open around its text: text directly inside a<table>is moved before the table by the parser, so two equal texts could line up in the wrong order and a change was applied to the other node. Such updates, and any start tag the parser would move out of a table, now take the full parse. The text fast path also falls back to a full parse for every<style>or<script>that follows an<svg>or<math>start tag; it used to count</svg>/</math>to decide when foreign content closed, and the tree builder ignores some of those end tags (a stray</math>inside<svg>, an</svg>insideforeignObjectordesccontent), so a text change could still be sent to the wrong node. The serializer also starts from the real namespace of the root: the parser reports it, so adj-rooton an element inside<svg>or<math>(<svg><g dj-root>) writes its<style>/<script>text escaped, as does itsdj-update="ignore"cache, and anInsertSubtreepayload is written for its parent's namespace; the text fast paths run only under an HTML root. Regression cases inpython/tests/test_vdom_foreign_raw_text.py,python/tests/test_vdom_html_raw_text_verbatim.pyand incrates/djust_vdom/src/lib.rs. - A dj-root on an SVG or MathML element keeps its children's namespace through
html_update,html_recoveryand the morph of a prerendered page on mount. These three paths parsed the root's inner HTML in an HTML context (DOMParser, orinnerHTMLon a<div>), so every child of a dj-root placed on an SVG or MathML element came out as an HTML element and replaced the SVG / MathML element of the full render: an<iframe>,<a>,<form>,<details>or<video>written there was a working HTML element after the update, where the full render has an inert SVG or MathML element.html_updateandhtml_recoverynow parse the frame as the root's children in the root's namespace, inertly (_parseRootFrameHtml), and the mount morph parses it in a detached element of the root's own namespace and name (_parseRootHtmlLive). A dj-root on an HTML element parses exactly as before. Regression tests intests/js/full_html_frame_namespace.test.js. html_updateandhtml_recoverykeep the content of a<noscript>as text. The page reads a<noscript>with scripting enabled, so its content is text, and the server writes that text as it is (#3045). The client parsed these frames with scripting disabled (DOMParser), which reads the same content as markup, and the morph then put those elements into the page: an<img>or<a>written inside<noscript>became a working element after the update. When the inert parse of a frame holds an HTML<noscript>, the client now parses the frame again as the mount morph does, in a detached element of the live document, which reads it with the page's scripting flag. Frames without a<noscript>keep the inert parse. Regression tests intests/js/full_html_frame_namespace.test.js.- A node inserted inside
<svg>or<math>by anInsertChildpatch keeps the SVG / MathML namespace. The client chose an inserted element's namespace from its name alone: an allow-listed HTML name (iframe,a,form,details,video) inside<svg>or<math>became a working HTML element where the full render has an inert SVG or MathML element, and MathML names became a<span>. The client now derives the namespace from the element the node is inserted into, by the same rules a page parse follows (foreignObject,desc,title, the MathML text elements and HTMLannotation-xmlhold HTML), and creates allow-listed names, MathML element names included, in that namespace. Names outside the allow lists still become a<span>, and an element under an HTML parent is created as before. Regression tests intests/js/insert_subtree_namespace.test.js;tests/playwright/test_insert_subtree_namespace.pycoversInsertChildin Chromium too. - The children of a placeholder for an SVG or MathML element keep their namespace. An
InsertChildpatch turns an SVG or MathML element outside the client's allow lists (filter,fe*,animate*,set,mstack, an unknown name) into a<span>, and then created its children inside that HTML<span>: an<iframe>,<a>,<form>,<details>or<video>there became a working HTML element, where the full render has an inert SVG or MathML element. The children are now created in the namespace of the element the<span>stands in for. The placeholder itself stays a<span>. Regression tests intests/js/insert_subtree_namespace.test.js. - An inserted
{% if %}block inside<svg>or<math>keeps the SVG / MathML namespace. The client parsed everyInsertSubtreepayload in an HTML<template>, so the elements of a conditional block inside<svg>or<math>came out as HTML elements, while the full render has them as SVG or MathML elements: an<iframe>,<a>,<form>,<details>or<video>written there is inert in the full render but was a working HTML element after the patch. The client now parses the payload as children of the element it is inserted into: inside an<svg>or<math>wrapper for an SVG or MathML parent (a MathMLmi,mo,mn,ms,mtextorannotation-xmlparent is reproduced as well), and as HTML, as before, for an HTML parent and for the HTML integration points (foreignObject,desc,title, andannotation-xmlwith an HTMLencoding). The server writes the payload for that parse. An HTML<noscript>in an inserted block is written with its text escaped, so its content stays text as in the full render. Regression tests intests/js/insert_subtree_namespace.test.js,python/tests/test_vdom_foreign_raw_text.pyandcrates/djust_vdom/src/lib.rs;tests/playwright/test_insert_subtree_namespace.pycompares real patches with a full render in Chromium. - The mount morph no longer runs a MathML or SVG
<script>. After the morph of a prerendered page, the client re-creates each classic<script>in the dj-root as a new HTML<script>so that inline page scripts run (#1848). It also matched<script>elements inside<math>and<svg>and re-created them as HTML scripts, so a MathML<script>, which never runs in a full render, ran after the morph._runInsertedScriptsnow re-creates only HTML<script>elements. Regression test intests/js/mount-morph-script-exec-1848.test.js. - Escape inside a dialog could click an unrelated
dj-clickcontrol (#3300). For anyrole="dialog"the built-in keyboard handling pressed Escape by dispatching the firstdj-clickcontrol it found in the dialog (a Delete or Confirm button, or a Next button that needs its value) when the dialog had no stock close button, and it acted again on keys that a component's own handler had already taken. It now uses only an explicit close control, the stock modal's.dj-modal__closeor anydj-clickcontrol markeddata-dj-close, and leaves a key alone when a handler closer to it has calledpreventDefault. A customrole="dialog"that relied on the old fallback should mark its close controldata-dj-close. The first time Escape finds a dialog withdj-clickcontrols but no explicit close control, the console says so (once per page) instead of pressing one. Escape now presses that control with a real click, so the event carries the control's owndata-value. Djust's own dialogs carry the marker: the ImageLightbox close button (class, tag and Rust handler), the Tour's Skip button, and the theming modal's close button, so Escape closes them and tells the server as before.data-dj-closeis a marker, not an event argument, and is not sent to handlers. - Live transports follow Django's
LoginRequiredMiddleware. Withdjango.contrib.auth.middleware.LoginRequiredMiddleware(Django 5.1+, or a subclass) inMIDDLEWARE, a view mounted over a live transport (WebSocket, SSE, API dispatch,mount_batch) now gets the answer the middleware gives for its page: an anonymous user is sent to the login page, where the middleware would send them (itslogin_urlandredirect_field_namefor that route), unless the view is public. A view is public when itsdispatchcarries Django'slogin_not_required(@method_decorator(login_not_required, name="dispatch"), public everywhere), or when the URLconf routes that view class throughlogin_not_required(MyView.as_view()), or when the request's own route serves the view and the installed middleware (subclass exemptions included) lets it through. A view the URLconf does not route, mounted beside the page view or hydrated as a lazy child over the WebSocket, needs thedispatchmarker to be mounted anonymously; naming a public page's URL does not make it public. On SSE and the API the request is the endpoint's, so a view served there anonymously needs thedispatchmarker or alogin_not_requiredroute of its own (and the endpoint itself must be exempt from the middleware for the request to arrive). Views rendered inside a page ({% live_render %}children) take their page's answer, as Django never checks what a page includes, and a page's own HTTP GET and POST are left to the middleware itself. djust's ownlogin_required = Falseis not an exemption, as it is not for the middleware. Without the middleware, or on Django < 5.1, nothing changes. djust serve --loops N --udsleft a stale socket when it was stopped just as it started. The server created the socket file before it installed its SIGINT/SIGTERM handlers, so a signal in that window ended the process with status -15 and left the file behind, and the next start failed with "address in use". The handlers are now installed first; a signal received before the event loops exist is recorded and the server shuts down cleanly (exit status 0, socket file removed). The shutdown never removes a socket path it did not create: after a failed bind the path belongs to another server and is left alone.- Push scopes are tenant-scoped for tenant views. The group of a
push_scopenow includes the tenant, so two tenants that use the same room name or document id each have their own: aTenantMixinview joins the scope groups of its own tenant, andpush_to_view(..., scope=...)/apush_to_viewtaketenant=(a tenant id or aTenantInfo), defaulting to the tenant bound to the running context. A push to a view that is not tenant-scoped is unqualified, and group names for such views are unchanged. A tenant view whose tenant did not resolve receives no scoped push, and a scoped push to a tenant view with no tenant reaches none; each logs one warning. Push hooks,handle_infoand thestart_asyncwork they queue now run with the view's tenant bound, as event handlers do. From a Celery task or management command, passtenant=. During a rolling upgrade, pods on the old and new versions name a tenant view's scope groups differently until the old pods are gone. {% masonry_grid %},{% dashboard_grid %}and{% audit_log %}escape caller values in the Rust template engine. The component classes and the Django template tags escape each masonry item'scontent, each dashboard panel'scontentand each audit-log column name (written into the cell'sclassattribute); the Rust-engine handlers wrote them unchanged. They now escape them the same way. The Rust engine passes list arguments to component handlers as data, so markup marked safe inside items is escaped there too; render such items with the component class or the Django tag. TheMasonryGridandDashboardGriddocstrings and the gallery examples now show text content. Tests inpython/djust/tests/test_rust_handler_item_escaping.py.- Show deferred state saves as a nonblocking saving status that clears after recovery, while retaining error events and the development overlay for persistent failures.
Security
The djust admin change list filters and sorts only on declared fields.
ModelListView.apply_filteraccepts the field names in the ModelAdmin'slist_filter(get_list_filter()), andsort_byaccepts the sortablelist_displaycolumns and the fields named inordering(get_ordering()), as Django's admin does. The queryset applies the same check to the stored filters and ordering, so any other name (another lookup, a path through a relation, an expression) is ignored and logged at debug level. The filter list on the page now comes fromget_list_filter()as well. A custom change-list template that filters or sorts on other fields must declare them inlist_filter,list_displayorordering. The auth admin's social accounts list sorts only on its own columns.The djust admin loads rows through the ModelAdmin's
get_queryset(). ADjustModelAdminthat scopes rows inget_queryset(request)was honoured by the change list only: bulk actions ran on the selected primary keys throughModel.objects, and the change and delete views loaded their object withModel.objects.get(). As in Django's admin, an action now runs on the selected rows thatget_queryset(request)holds (other selected keys are ignored), and the change and delete views load their object with the newDjustModelAdmin.get_object(request, object_id), which looks it up in that queryset; a row it leaves out, or a key that names no row, is a 404.A live view whose
mount()raises is no longer left mounted. An exception frommount()(or the state restore,handle_params(),connected()or the first render) used to answer with an error frame and keep the view as the connection's mounted view, so later events still ran its handlers, and it stayed in its channel groups with its presence tracked. Such a mount now ends like a refused one on WebSocket and SSE: aPermissionDeniedis answered as the authorization check answers it (code: "permission_denied"; the page view's socket closes with 4403, a lazy or batched view getsview_refused), any other exception keeps its error frame, and in both cases the view is released, its groups are left, its presence is untracked and its tick stops. A view whose object permission is refused aftermount()also untracks its presence. The HTTP fallback and the HTTP API already refused such a request.Live transports enforce or refuse access control that only the HTTP request runs. The WebSocket and SSE mount, events, the HTTP POST event fallback, the API and
{% live_render %}children never call a view'ssetup(),dispatch(),get()orpost(), and never run the wrappers the URLconf puts aroundas_view(). djust replays its own declarations and Django'sLoginRequiredMixin,PermissionRequiredMixinandUserPassesTestMixinthere, but a customAccessMixinsubclass that checks the user in its owndispatch(), a@method_decorator(...)ondispatch,getorpost, an overridden method that checks the user, or a URLconf wrapper such aspath("x/", login_required(MyView.as_view()))did not run at all.check_view_authnow:- replays Django's
login_required,staff_member_required,user_passes_test(...)andpermission_required(...)applied with@method_decorator, wrapped aroundas_view()in the URLconf (read from the server's URLconf, not from the URL the client sends) or by anas_view()override, and other packages' decorators built onuser_passes_test. The same users are admitted as on the HTTP request; an authenticated user who fails the test getsPermissionDenied, where Django's HTTP response would redirect to the login page. Async test functions are awaited (called directly on the event loop's thread, the check refuses instead of blocking).login_not_requiredis not access control. A decorator applied only topostis checked for every event on the page (the view's, its components', its embedded views' and uploads), not for the mount; - refuses a view whose custom
AccessMixinsubclass definesdispatch(), unless it declarespermission_requiredorcheck_permissions()(login_required = Truealone does not cover it); - refuses a view whose
setup()/dispatch()/get()/post()override, a decorator on one (also under another decorator stacked above@method_decorator, or returned by a project decorator that delegates to it), or a URLconf wrapper checks the user (any attribute of it, such asis_staff,groupsor a role) and denies by any means (raises, includingPermissionDeniedandHttp404; callshandle_no_permission()orredirect_to_login(); returns a redirect, an error response or a page of its own such asrender(...)orHttpResponse(...); callsget_object_or_404()), unless a declared rule covers it (login_requiredcovers a check that reads onlyis_authenticated/is_anonymous); - refuses a view with any other decorator or URLconf wrapper that is neither Django's nor djust's, unless it declares
permission_requiredorcheck_permissions().
Permitted users of views using Django's
login_required,staff_member_required,user_passes_testandpermission_requireddecorators and wrappers, or its three auth mixins, are unaffected. Views whose access check lives in their ownsetup(),dispatch(),get(),post(), decorators or URLconf wrappers are refused for every user until the rule is declared. The HTTP GET page, which Django's dispatch already gated, is unchanged. System checkdjust.S012(an Error) names each refused view defined in the project;djust.V013(a warning) names overrides that only read the user, which are not refused, refused views that an installed package defines, and URLconf routes whose wrapper does not usefunctools.wraps(djust cannot read it).djust.S012no longer reports Django's own decorators ondispatch(), which are replayed. Onlydjustanddjust.*modules are treated as framework code: apps and packages nameddjust_*are inspected, djust-auth'sLoginRequiredLiveViewMixinis read aslogin_required = True, and djust-tenants'TenantMixinis not reported.LoginRequiredLiveViewMixinindjust.authnow also setslogin_required = True. Upgrade note: a view refused this way must declare its rule. A public view that keeps a rate-limit, CSP or logging decorator or URLconf wrapper definescheck_permissions()returningTrue.login_required = Falseis not a declaration.- replays Django's
MCP tools keep filesystem paths inside the project.
find_handlers_for_templateread any absolute path that existed, so it confirmed which files existed anywhere on disk and returneddj-*-shaped strings from them. It now reads an absolute path only when the file resolves (symlinks and..followed) inside a directory the template loaders serve or insideBASE_DIR, and answers any other absolute path like a missing template.seed_fixturescompared paths with a string prefix, so/app-datapassed for aBASE_DIRof/app, and skipped the check whenBASE_DIRwas unset; it now compares whole path components and refuses to load anything withoutBASE_DIR. Both use one containment helper.The live mount checks object permission after
handle_params(). The WebSocket and SSE mounts ranhas_object_permission()aftermount()but beforehandle_params(), so a view that selects its object from the URL's query parameters inhandle_params()was checked against the objectmount()chose and then rendered the one the parameters selected. The mount now runsmount(),handle_params(), the object check, and thenconnected()and the first render: the order the HTTP GET render andurl_changenavigation already used. A refused mount still sends apermission_deniederror frame and closes the WebSocket with 4403.One
@rate_limitbudget per handler function, whatever name reaches it. The shared store keyed buckets on the caller and the name the route used (the client-suppliedevent_nameon WebSocket/SSE, the function name on the HTTP API), so a method reachable under two names (an alias attribute, or a second name bound inmount) had a separate budget per name. The bucket is now keyed on the function that actually runs (__func__of a bound method), accepted only when that function is declared on the owning class (or, for an ADR-031 component, on the class behind the bound proxy); only the exact builtinstaticmethod/classmethodwrappers are unwrapped, and membership is compared withis. A handler whose identity cannot be determined this way (a property or other descriptor, afunctools.partial, a callable instance, a function from an unrelated class) is refused with a named error rather than given a budget of its own, and two handlers that resolve to one key with different limit settings are refused rather than sharing the first one's settings. Functions made by one factory keep separate budgets. Tests:python/djust/tests/test_ratelimit_post_fallback.py.@rate_limitnow applies on the page-POST fallback. The per-handler@rate_limitwas checked on WebSocket/SSE and on the HTTP API, but the page-POST fallback (mixins/request.post), which also runs an@event_handlerfrom the request body, did not consult it, so calls through it did not use the shared per-caller budget that F27 documents as "one budget per handler regardless of transport". The fallback now checks the limit at the same point as the WebSocket path (after the decorator and permission checks, before the handler runs) and answers429withcode: "rate_limited"in its existing JSON error shape. Both HTTP transports share one implementation,djust.rate_limit.http_handler_rate_check, and one caller-key helper,caller_key_for_request(still behindresolve_client_ip, soDJUST_TRUSTED_PROXY_COUNTstill applies); the HTTP API's behaviour is unchanged. Tests:python/djust/tests/test_ratelimit_post_fallback.py.Development dependency security: Update
source-map-jsto 1.2.2, fixing CVE-2026-93749 (indexed source-map offsets can block the build/test event loop).The theme editor's inline preset and design-system data is now escaped for its
<script>block. The editor embeddedjson.dumpsoutput in an inline script with|safe, andjson.dumpsleaves<,>and&raw, so a registered preset or design-system value containing</script>closed the element. Both blobs now go throughescape_json_for_script, the same helper the debug panel uses; the data the page's script reads is unchanged. 3 cases inpython/djust/tests/test_theming_editor_json_script.py.Theming URLs use a shared script-scheme denylist (#3400, #3407, #3418). Data-derived URLs in navigation, breadcrumbs, pagination, avatars and page tags are neutralised to
"#"forjavascript:,vbscript:anddata:schemes, including whitespace and control-character obfuscation. Image sources retaindata:image/*; links do not. Other schemes, includingsms:,geo:and app deep links, remain unchanged. Developer-supplied buttonhref=and passthrough URL attributes still raiseValueError. Navigation covers dictionary and object items, iterable collections and sidebar sections without mutating caller-owned items. Pagination checks every page, first, last, previous and next URL after formatting.
Documentation
- Python 3.15 and abi3 wheel evaluation (#3255).
docs/performance/python-3.15-and-abi3-wheels-2026-10-05.mdrecords what blocked 3.15, the install-tested wheel matrix (cp311 to cp315 and cp314t, built the way the release workflow builds them), and measured evidence for abi3 (acp311-abi3wheel built from the same tree: size, install on 3.11 to 3.15, hot-path timings against the version-specific wheels, and what abi3 cannot do). It decides nothing: the release-wheel strategy and the Python floor are unchanged. Three Unicode 17 pins in the parity tests (CPython 3.15 ships Unicode 17.0) and one CPython 3.15 change in%-format overflow handling are now interpreter-aware. - The AI Guardrails guide is now in the docs navigation (#3329).
docs/website/guides/ai-guardrails.mdwas missing fromdocs/website/_config.yamland the guides index, so a docs build that checks navigation reachability reported it as unreachable and served it only through the fallback grouping. It is listed after the MCP Server guide in both. - The "Several LiveViews on One Page" guide is now in the docs navigation (#3333).
docs/website/guides/multiple-views.mdcame in with #3333 and was in the guides index but not indocs/website/_config.yaml, so a docs build that checks navigation reachability reported it as unreachable. It is listed after Sticky-Child State Persistence, at the order its front matter gives. dj-inputis no longer described as firing on every keystroke (#3356). The template cheatsheet,dj-paste,live_input, best-practices anddocs/aipages,llms-full.txtand the schema description (which feeds the generated AI-context files anddjust_schema) now say what the client does: text-like fields are debounced 300 ms by default,rangeandcolorare throttled 150 ms,number100 ms, checkbox, radio and select send immediately, anddj-debounce/dj-throttleoverride it (dj-debounce="0"sends every keystroke). The pins inpython/djust/tests/test_schema_directive_pins_3327.pyread the client's rate-limit table and each doc, so the wording cannot drift from the code again.- Correct HTML parser release citations (#3389). The tokenizer comments and header accuracy test now identify CPython 3.12.12 as the HTML5-spec rewrite release, qualify the earlier 3.10/3.11 patch releases, and date the RAWTEXT expansion to 3.12.13. Parser behavior is unchanged.
- Documented where
striptagsintentionally differs from Django'sstrip_tagson newer CPython. The templates guide has a new "striptags and the Python version" section: the measured differences per interpreter over the 4,000-value differential corpus, the eight additional values (abruptly closed empty comments such as<!--->a<!--->) that CPython 3.13.15 and 3.14.7 added, the end-of-input&/&#change from 3.13.10 and 3.14.1, the depth guard, and a project filter that returns Django's exact result.test_striptags_parity_2273.pyre-derives every documented input, answer and count from the pinned fixture, so the page cannot drift from it.striptagsbehaviour is unchanged.
Deprecated
- Dict-style access to
blockin a Python-bridged tag is deprecated (#2918).context["block"]["super"],block.get("super"),block.values(),block.items()anddict(block)(the shape of thedictthe tag received before{{ block.super }}became lazy) still work, but each read now emits the standard djustDeprecationWarning, pointing at the tag's own line: deprecated since 1.3, removed no earlier than 2.0.0, usecontext["block"].super()instead. Each read still renders the parent once with no memoization, and the parent's own exception class reaches the tag'sexceptunchanged. Django's own resolver (Variable("block.super"),{% blocktranslate with s=block.super %}) (and djust's own Rust resolver) is recognised and takes thesuper()method as in Django, with no warning, even whenVariable._resolve_lookupis wrapped by a profiler or monkeypatch. Covered bypython/tests/test_block_super_bridge_lazy_2918.py.