djust 1.3.0rc8

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

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-text and --warning-text colours, so a fill read as text meets AA in every preset and mode (#2885). primary and the status colours are fills (a *_foreground label 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-text in Tailwind, the gallery editor's live preview and the inspector. 26 regression cases in python/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-hook that no shipped script answered; components/static/djust_components/image-lightbox.js, file-tree.js, resizable-panel.js and animated-number.js now 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 to min_size and max_size, and announces the size as a bubbling dj-resize event. 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 in window.DjustHooks and 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-hook that no shipped script answered; components/static/djust_components/activity-feed.js, terminal.js and tour.js now do, and the catalogue page of each includes its script. A streaming ActivityFeed puts pushed events at the top, keeps max_items rows, 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 optional max_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-items on a streaming feed and data-line-numbers / data-max-lines on 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 in window.DjustHooks and 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-hook that no shipped script answered; components/static/djust_components/dashboard-grid.js, mentions-input.js, cursors-overlay.js and collab-selection.js now 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} through move_event and {id, width, height} through resize_event in whole grid units (untrusted: the docstring has handlers that validate and clamp them). MentionsInput: typing @ offers the people in users, 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's value, field, key and code kept; 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. CollabSelection gains an optional target selector: with it the selections' start/end offsets 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 in window.DjustHooks and 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 optional resize_event of ResizablePanel and toggle_event of FileTree.

  • ImageCropper now ships its interaction, and a validating cropper for the server (#2985, batch 5). The component rendered a dj-hook that no shipped script answered. components/static/djust_components/image-cropper.js keeps 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 above min_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 to crop_event: the image is never uploaded and no pixel is read. New djust.components.cropping: crop_box validates 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) and crop_image (Pillow, imported on demand) refuses an image over max_pixels from 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 new alt kwarg 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 in window.DjustHooks and 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-hook that no shipped script answered and never sent a file anywhere. With the new upload kwarg (the allow_upload() slot name) its input carries dj-upload and its drop zone dj-upload-drop, so chunking, progress events, cancellation and every server-side check are UploadMixin's; components/static/djust_components/image-upload-preview.js adds 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 by djust:upload:progress with Cancel (window.djust.uploads.cancelUpload), Remove, UX-only pre-checks for type (accept), size (new max_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 than display: none, so the picker is reachable by keyboard. The event kwarg 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-drop and 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 in window.DjustHooks and 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-hook that no shipped script answered. components/static/djust_components/map-picker.js now 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) to pick_event. Leaflet 1.9.4 (BSD-2-Clause) is vendored under ADR-040 (leaflet in the components djust_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, plus label for 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 (new data- attributes and an optional aria-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 in window.DjustHooks and 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-hook that no shipped script answered. components/static/djust_components/signature-pad.js draws 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 to save_event (and the hidden form field), never longer than the new max_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=False turns it off). New djust.components.signature.decode_signature_data_url validates 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-label on 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 in window.DjustHooks and 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=...) and FileTree(toggle_event=...) send what the reader did to the server (#2985). Both are optional and additive. With resize_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 own dj-resize CustomEvent still fires); render the remembered size back as initial_size. With toggle_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 with expanded; 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 a data-resize-event / data-toggle-event attribute 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.

  • Terminal takes auto_scroll (#2985). auto_scroll=False makes 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 added data-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-hook that no shipped script answered; components/static/djust_components/sortable-list.js, sortable-grid.js, json-viewer.js and log-viewer.js now 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: [...]} through move_event; their items gain a data-key so 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 to stream_event, and honours max_lines, show_line_numbers and filter_level through new data-* attributes. The scripts register in window.DjustHooks and 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 every event_security mode (strict, warn and open) 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 check djust.V021 reports such an override (Info), or one that adds @event_handler (Warning). It is not an event handler: djust.V004 no longer suggests @event_handler for it, djust.T019 reports a template binding to it in every mode, and LiveViewTestClient.send_event refuses it while the new send_push helper 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 a TypeError. Existing handle_* push targets and @event_handler methods are unchanged. 41 regression cases in python/djust/tests/test_push_handler_marker_3002.py.

  • Add experimental, opt-in djust.clocks process-local RoomClock and SharedPoll: 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() and disconnected() are real LiveView hooks (#3007). connected() runs once per live (WebSocket or SSE) mount, after mount() (or a state restore) and handle_params() and before the first render, so state it sets is in the first frame; raising fails the mount like mount() does. disconnected() runs once for each view that reached connected(), when its live mount ends: the socket or stream closes, or a live_redirect, a second mount frame, an unmount frame or a revoked authorization releases the view. Both are regular methods (not async def) and run on a worker thread (the ORM works), disconnected() under the tenant the view mounted with, and an exception in disconnected() 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 (an async 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 run connected() of the new view before disconnected() of the old one, so key claims by connection. Both are listed in djust.schema.LIFECYCLE_METHODS again, and the LiveView API reference states the lifecycle contract, including what is not guaranteed. 25 regression cases in python/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 mount names with a target_id was already a view of its own; an SSE session hosted one view, so a dj-lazy container never hydrated over it and an event from inside one either did nothing or, through sendEvent's fourth argument being a keepalive flag, could lose its address. An SSE session now hosts the page view plus views mounted beside it: a mount POST that names a target_id adds one with its own runtime, state, saves, authorization and teardown, its frames carry the target_id, unmount releases one, a navigation and the end of the stream release them all, a frame for an address with nothing mounted is refused with code: "view_unavailable", mount and unmount frames count against the session's rate limit (rate_limited), and the client mounts them again after a reconnect. A dj-hook pushEvent now works over SSE (it used to need the WebSocket), addressed to the view it sits in. Without a stream (a browser with no EventSource) a lazy view's hydration reports view_unavailable instead of leaving a container that looks live. State isolation, on every transport: an explicit view's persist="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 after live_redirect, a shared render lock, and a refused login-required lazy view redirecting the whole page. Covered in python/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.js and tests/playwright/test_multi_view_sse_http.py.

  • AI discovery INFO checks (#3448). Add djust.I001 agent discovery hints, djust.I002 for LiveView projects without theming, and djust.I003 for 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 INFO now fails unless I001 is silenced; unsilenced I002/I003 can also fail this threshold. I002 also prints on runserver/migrate for LiveView projects without djust.theming. Use SILENCED_SYSTEM_CHECKS or DJUST_CONFIG["suppress_checks"] per ID; DJUST_AI_HINTS=0 silences I001 only.

  • AI capability discovery (#3448). Add djust_ai inventory, suggest, and manifest, exposed through djust ai, plus MCP list_ui_components and get_ui_component. The shared catalog supplies component purposes, keywords, props, and examples. Suggest/manifest run in-process; inventory uses configured settings or a validated nearby manage.py entry 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-ui opts out of UI rules, while --ast-no-ui-cache disables 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 in python/tests/test_audit_ui_3448.py (170 collected cases including parametrization).

    Behavior change: Projects running djust_audit --ast --strict in CI will now fail on X1xx warnings. Migrate by adding --ast-no-ui, or fix findings / add specific same-line noqa comments. Disabling cache writes does not disable the warnings.

Changed

  • djust init reports the documented TEMPLATES shape, and djust init --templates adds it (#2884). djust init never edits TEMPLATES: it prints an informational step and the entry that puts DjustTemplateBackend first (with "NAME": "djust", matching the installation guide; djust new writes the same entry without a NAME, so its engine alias stays template_backend). With --templates it appends a marked block to a TEMPLATES it can read with certainty: the djust entry mirrors the first DjangoTemplates entry's DIRS and supported OPTIONS, and the project's own entries stay after it unchanged. A TEMPLATES that already lists the djust backend is left alone, and one init cannot read is ATTENTION only when --templates was 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 the DjangoTemplates entry once djust is first. 32 regression cases in python/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, link and link_hover tokens 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 --code surface of syntax comments are guarded: a move that cannot keep them is held (tailwind and everforest light muted_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-foreground is 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 --background with its --foreground fill, the rich-select status variants read the derived *-text colours of their wash, and the data-card filter chip no longer swaps --accent under its active label on hover. A test scans every rule that sets color from a *-foreground token (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 stripe link, github and stripe muted text), which keep that hex, the two held code-surface tokens, and the 131 input borders (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-accent paints as a background. scripts/fix_theme_text_contrast.py --include-substantial applies and checks this.
  • theme_context renders its HTML lazily; its settings values stay eager (#3028). The processor used to render theme_head, theme_panel, theme_mode_toggle, theme_preset_selector and theme_switcher on every request that built a RequestContext, 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), and theme_preset, theme_mode, theme_resolved_mode, theme_pack, theme_presets and components_gallery_url stay 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 longer str instances; {{ }}, |safe, |length, {% if %} and == against a string still work, and str(...) gives the text. Python that needs a real str ("".join, re, startswith, os.fspath, plain json.dumps) raises TypeError on one; the guide lists the cases. On the LiveView (Rust) renderer a chunk reaches the template as its rendered string, so in, ==, |add, |slice and |default see the same text as before, and a LiveView session whose Rust state was stored by an earlier build (which kept the rendered theme_head in that state) drops the stale entry on the next sync instead of rendering it escaped. A page that never reads theme_head no longer makes django-csp mint a nonce for the response. djust_theming.E001 stays a Warning, and its docs entry no longer cites the per-request cost. Regression cases in python/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_mount redirect) answered with the page-level navigate frame, so one refused dj-lazy view sent the whole page to the login page. It now answers with a view_refused frame addressed to its container (over mount_batch, an entry of the reply's refused[] array, and no navigate[] entry): the client replaces that container's content with a short role="alert" message and, for a login or redirect, a link to the sign-in URL, sets data-djust-refused="<reason>" on it, forgets the view and fires djust:view-refused so 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 answers code: "view_unavailable" like SSE. The page view's own refusal is unchanged. Upgrade note: a client older than this release does not know view_refused and leaves a refused lazy view's container empty instead of navigating. docs/website/guides/multiple-views.md documents 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 with view_unavailable, and a page of lazy views only needs a minimal page view over SSE. Covered in python/djust/tests/test_multi_view_refusal_3252.py, tests/js/view_slots_3252.test.js, tests/js/sse_view_slots_3252.test.js and tests/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 def handlers overlap (#3252). The views of a connection (the page view and the dj-lazy / mount_batch views 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 as request_html and upload_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 plain def, and the connection-cleanup step every inbound frame makes before dispatch, run on it, so while a def handler blocks it no other view's frame is answered (two strict expected failures pin this); async def handlers, start_async work 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 at rate_limit.upload_burst x max_message_size bytes, about 25 MiB; past it the upload is stopped with an upload_progress error); a view with 64 turns already waiting refuses further frames with code: "view_busy" (with ref, target_id and event; not retried). 33 regression cases in python/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.15 shards failed in uv sync before any test ran: fourteen locked packages had no cp315 wheel 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.lock now carries versions with cp315 wheels (cffi, librt, msgpack, orjson, rpds-py, ujson, uvloop, watchfiles), and the dev extra pins pydantic>=2.14.0b2 for Python 3.15 only, because pydantic-core has no cp315 wheel before the 2.48 series that only pydantic 2.14 (still a beta) pulls in. Python 3.11 to 3.14 keep their existing pydantic. autobahn, httptools, pyyaml, zope-interface, zstandard and, on macOS, watchdog still 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. The py3.15 cell stays allow-failure until 3.15.0 is final.
  • Markdown output keeps at most 32 containers open at once. djust.render_markdown and {% 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 while MAX_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 as djust._rust.MAX_MD_NESTING. Regression cases in python/tests/test_markdown_nesting_bound.py and the markdown unit tests of djust_templates.
  • A server push is stamped with its tenant, and a tenant view applies only its own. push_to_view / apush_to_view now stamp every push with the tenant it is for (tenant=, defaulting to the tenant bound to the running context), including pushes without scope, and a TenantMixin view 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; pass tenant=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 the Terminal component, 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 in python/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 the dj-root element no longer walks the raw tree recursively either. The limit is exported as djust._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 in python/tests/test_vdom_nesting_bound.py and crates/djust_vdom/tests/test_nesting_depth_bound.rs.

Fixed

  • Text painted with primary, brand, info, success or warning now reads the derived *-text colour (#2885). .text-primary, .text-success, .text-warning, .text-info, .btn-link, links, alert, toast, badge and status text and the alert/toast icons use var(--primary-text, var(--primary)) and its siblings; backgrounds, borders and the status dots keep the fill (-danger included: that dot is restored to its pre-#3320 fill) (anything drawn from currentColor on a text element, such as an underline or an icon stroke, follows its text colour). The 347 primary / info / success / warning as-text pairs that were exempt in A11Y_EXEMPTIONS are now measured through the derived colours (the contrast matrix and djust_theming.W001) and pass with no exemption. Override the -text colour 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, link and link_hover tokens 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; a link and its hover move together and the hover keeps its side of the link. That clears 172 documented failures from A11Y_EXEMPTIONS and 22 failing hover pairs. No fill, background or border token changes, but the CSS also paints --muted-foreground as dots, switch tracks and scrollbars, which shift by the same step. scripts/fix_theme_text_contrast.py proposes, 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 in python/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 read block.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. The block a bridged tag receives is now a lazy object whose super() renders the parent when it is read, and renders it again on every read (no memoization, as Django's BlockNode.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's except and a {% blocktranslate with s=block.super %} unchanged. It has Django's BlockNode shape: 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 named block is no longer replaced. 30 regression cases in python/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_lines set, 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's scrollTop itself no longer stops the follow. The new Terminal hook has the same follow logic and the same fix. auto_scroll=False on 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__value set 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 first dj-root wins, else the first dj-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-case DATA-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.py under GitHub Actions' default bash -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 pipefail does nothing about -e); the observed result (run 37672728078) was a red cron with the findings thrown away, and because steps.audit.outputs.flagged was never written the summary fell through to its else branch 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 failed gh read 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 — 0 clean, 1 findings, 2 could not complete — with a final AUDIT_VERDICT: clean|findings|incomplete line as the machine-readable verdict, and a failed gh/git read raises AuditIncomplete instead of returning empty. The workflow dispatches on that verdict: clean stays green, findings stays green with one ::warning:: per flagged row, and incomplete (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 in tests/test_retro_gate_audit_workflow_3129.py, including the real script driven through stub gh/git reads for the failed-read and crash paths, and a forged AUDIT_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, which 50-lazy-fill.js applies (its DOMContentLoaded auto-scan already covers the activator running before the bundle defines lazyFill). 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, the visible trigger 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. The dj-lazy attribute 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 in python/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-view and dj-lazy bytes 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 |safe never 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 Decimal refusal message now spells the type name the way the running interpreter does (#3255). djust reproduces CPython's 'X' object is not iterable wording, and it carried decimal.Decimal as a literal. That is the C _decimal accelerator's spelling — a static type, so its tp_name carries the module. An interpreter without _decimal falls back to the pure-Python _pydecimal, a heap type whose tp_name is the bare Decimal, 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 class is_decimal already 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 and track_performance no longer call the deprecated asyncio.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 use asgiref.sync.iscoroutinefunction (already a Django dependency), which gives the same answer for an async def and for a sync wrapper marked with markcoroutinefunction on every supported Python. python/djust/tests/test_asyncio_iscoroutinefunction_3255.py scans the package for the old spelling and runs each decorator with DeprecationWarning as an error.
  • A dj-view or dj-root on <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.T025 keeps 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_textarea corpus 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 carries dj-root or dj-view is 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: a dj-root or dj-view on 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). A dj-lazy view'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.T025 no longer flags <body> (it still flags <html> and <head>, which are not supported). The client also keeps the data-djust-target address of a dj-view container when a page mount morphs it, so dj-lazy views inside a root mount.
  • Draft mode saved and restored only the fields present at page load (#3351). With DraftModeMixin, a data-draft field 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 a dj-lazy view 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 until clear_draft() removes the draft (nothing else does, logging out included); two roots sharing a data-draft-key overwrite 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 suite tests/js/draft-mode-after-init-3351.test.js and a real-browser script in tests/playwright/.
  • djust_ai_context left eleven directives out of the generated CLAUDE.md, .cursorrules and Copilot files (#3353). _section_directives walked a hand-kept category list that omitted performance, animation and recovery, so dj-prefetch, dj-key, dj-lazy, dj-virtual, dj-track-static, dj-transition, dj-remove, dj-transition-group, dj-view-transitions, dj-flip and dj-auto-recover were only visible through djust_schema and 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 in python/djust/tests/test_ai_context_every_directive_3353.py check that every directive in the schema is in all three formats.
  • dj-model follow-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. A contenteditable element now sends its text as value (with .lazy, when it loses focus), and dj-model.debounce-N is found on controls and contenteditable elements. 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 PresenceMixin view, 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/firstof literals, literal with bindings and block.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 replays cut normally; P preserves opaque and authored U+FFFD separators under cut. A value can no longer complete a comment, script or attribute that the authored text left open; ordinary output (csrf_token, djust_client_config, |safe markdown, SVG icons inside spaceless or cache) 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, including slice, 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 asyncio log with its message and traceback on Python 3.14. The save deadline waited with wait_for(shield(save)); since 3.14, asyncio.shield hands 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 with asyncio.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 pinned striptags reference records the html.parser change to abruptly closed empty comments (<!-->, <!--->) that those patch releases made, and the Unicode sweep of the title filter 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 style attributes. A colour, length, ratio or number that a component takes from its caller and writes into an inline style attribute 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 (with transparent and currentcolor), rgb()/rgba()/hsl()/ hsla() with plain numeric arguments, and var(--name) references; lengths accept numbers with a unit, the usual keywords, var(), and calc()/min()/ max()/clamp(). Valid values render exactly as before. Covers CursorsOverlay and CollabSelection (the user colour), CalendarView event colours, Meter segment colours, ColorPicker, kanban column colours, the data table colour scale, and the heights, widths, gaps, offsets and ratios of MapPicker, 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 are djust.components.utils.safe_css_color, safe_css_length, safe_css_ratio and safe_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 named script, style, noscript, xmp, iframe, noembed, noframes or plaintext verbatim, 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 (&lt; stays &lt;). The serializer now tracks the namespace while it writes (svg and math switch to foreign content; foreignObject, desc, title, MathML text elements and HTML annotation-xml switch 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 legacy document.write pattern) and <plaintext>. An inserted {% if %} block (an InsertSubtree payload) 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. The render_binary_diff text-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 &lt; 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 of render_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> inside foreignObject or desc content), 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 a dj-root on an element inside <svg> or <math> (<svg><g dj-root>) writes its <style> / <script> text escaped, as does its dj-update="ignore" cache, and an InsertSubtree payload is written for its parent's namespace; the text fast paths run only under an HTML root. Regression cases in python/tests/test_vdom_foreign_raw_text.py, python/tests/test_vdom_html_raw_text_verbatim.py and in crates/djust_vdom/src/lib.rs.
  • A dj-root on an SVG or MathML element keeps its children's namespace through html_update, html_recovery and the morph of a prerendered page on mount. These three paths parsed the root's inner HTML in an HTML context (DOMParser, or innerHTML on 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_update and html_recovery now 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 in tests/js/full_html_frame_namespace.test.js.
  • html_update and html_recovery keep 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 in tests/js/full_html_frame_namespace.test.js.
  • A node inserted inside <svg> or <math> by an InsertChild patch 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 HTML annotation-xml hold 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 in tests/js/insert_subtree_namespace.test.js; tests/playwright/test_insert_subtree_namespace.py covers InsertChild in Chromium too.
  • The children of a placeholder for an SVG or MathML element keep their namespace. An InsertChild patch 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 in tests/js/insert_subtree_namespace.test.js.
  • An inserted {% if %} block inside <svg> or <math> keeps the SVG / MathML namespace. The client parsed every InsertSubtree payload 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 MathML mi, mo, mn, ms, mtext or annotation-xml parent is reproduced as well), and as HTML, as before, for an HTML parent and for the HTML integration points (foreignObject, desc, title, and annotation-xml with an HTML encoding). 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 in tests/js/insert_subtree_namespace.test.js, python/tests/test_vdom_foreign_raw_text.py and crates/djust_vdom/src/lib.rs; tests/playwright/test_insert_subtree_namespace.py compares 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. _runInsertedScripts now re-creates only HTML <script> elements. Regression test in tests/js/mount-morph-script-exec-1848.test.js.
  • Escape inside a dialog could click an unrelated dj-click control (#3300). For any role="dialog" the built-in keyboard handling pressed Escape by dispatching the first dj-click control 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__close or any dj-click control marked data-dj-close, and leaves a key alone when a handler closer to it has called preventDefault. A custom role="dialog" that relied on the old fallback should mark its close control data-dj-close. The first time Escape finds a dialog with dj-click controls 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 own data-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-close is a marker, not an event argument, and is not sent to handlers.
  • Live transports follow Django's LoginRequiredMiddleware. With django.contrib.auth.middleware.LoginRequiredMiddleware (Django 5.1+, or a subclass) in MIDDLEWARE, 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 (its login_url and redirect_field_name for that route), unless the view is public. A view is public when its dispatch carries Django's login_not_required (@method_decorator(login_not_required, name="dispatch"), public everywhere), or when the URLconf routes that view class through login_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 the dispatch marker 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 the dispatch marker or a login_not_required route 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 own login_required = False is not an exemption, as it is not for the middleware. Without the middleware, or on Django < 5.1, nothing changes.
  • djust serve --loops N --uds left 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_scope now includes the tenant, so two tenants that use the same room name or document id each have their own: a TenantMixin view joins the scope groups of its own tenant, and push_to_view(..., scope=...) / apush_to_view take tenant= (a tenant id or a TenantInfo), 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_info and the start_async work they queue now run with the view's tenant bound, as event handlers do. From a Celery task or management command, pass tenant=. 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's content, each dashboard panel's content and each audit-log column name (written into the cell's class attribute); 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. The MasonryGrid and DashboardGrid docstrings and the gallery examples now show text content. Tests in python/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_filter accepts the field names in the ModelAdmin's list_filter (get_list_filter()), and sort_by accepts the sortable list_display columns and the fields named in ordering (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 from get_list_filter() as well. A custom change-list template that filters or sorts on other fields must declare them in list_filter, list_display or ordering. The auth admin's social accounts list sorts only on its own columns.

  • The djust admin loads rows through the ModelAdmin's get_queryset(). A DjustModelAdmin that scopes rows in get_queryset(request) was honoured by the change list only: bulk actions ran on the selected primary keys through Model.objects, and the change and delete views loaded their object with Model.objects.get(). As in Django's admin, an action now runs on the selected rows that get_queryset(request) holds (other selected keys are ignored), and the change and delete views load their object with the new DjustModelAdmin.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 from mount() (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: a PermissionDenied is answered as the authorization check answers it (code: "permission_denied"; the page view's socket closes with 4403, a lazy or batched view gets view_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 after mount() 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's setup(), dispatch(), get() or post(), and never run the wrappers the URLconf puts around as_view(). djust replays its own declarations and Django's LoginRequiredMixin, PermissionRequiredMixin and UserPassesTestMixin there, but a custom AccessMixin subclass that checks the user in its own dispatch(), a @method_decorator(...) on dispatch, get or post, an overridden method that checks the user, or a URLconf wrapper such as path("x/", login_required(MyView.as_view())) did not run at all. check_view_auth now:

    • replays Django's login_required, staff_member_required, user_passes_test(...) and permission_required(...) applied with @method_decorator, wrapped around as_view() in the URLconf (read from the server's URLconf, not from the URL the client sends) or by an as_view() override, and other packages' decorators built on user_passes_test. The same users are admitted as on the HTTP request; an authenticated user who fails the test gets PermissionDenied, 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_required is not access control. A decorator applied only to post is 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 AccessMixin subclass defines dispatch(), unless it declares permission_required or check_permissions() (login_required = True alone 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 as is_staff, groups or a role) and denies by any means (raises, including PermissionDenied and Http404; calls handle_no_permission() or redirect_to_login(); returns a redirect, an error response or a page of its own such as render(...) or HttpResponse(...); calls get_object_or_404()), unless a declared rule covers it (login_required covers a check that reads only is_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_required or check_permissions().

    Permitted users of views using Django's login_required, staff_member_required, user_passes_test and permission_required decorators and wrappers, or its three auth mixins, are unaffected. Views whose access check lives in their own setup(), 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 check djust.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 use functools.wraps (djust cannot read it). djust.S012 no longer reports Django's own decorators on dispatch(), which are replayed. Only djust and djust.* modules are treated as framework code: apps and packages named djust_* are inspected, djust-auth's LoginRequiredLiveViewMixin is read as login_required = True, and djust-tenants' TenantMixin is not reported. LoginRequiredLiveViewMixin in djust.auth now also sets login_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 defines check_permissions() returning True. login_required = False is not a declaration.

  • MCP tools keep filesystem paths inside the project. find_handlers_for_template read any absolute path that existed, so it confirmed which files existed anywhere on disk and returned dj-*-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 inside BASE_DIR, and answers any other absolute path like a missing template. seed_fixtures compared paths with a string prefix, so /app-data passed for a BASE_DIR of /app, and skipped the check when BASE_DIR was unset; it now compares whole path components and refuses to load anything without BASE_DIR. Both use one containment helper.

  • The live mount checks object permission after handle_params(). The WebSocket and SSE mounts ran has_object_permission() after mount() but before handle_params(), so a view that selects its object from the URL's query parameters in handle_params() was checked against the object mount() chose and then rendered the one the parameters selected. The mount now runs mount(), handle_params(), the object check, and then connected() and the first render: the order the HTTP GET render and url_change navigation already used. A refused mount still sends a permission_denied error frame and closes the WebSocket with 4403.

  • One @rate_limit budget per handler function, whatever name reaches it. The shared store keyed buckets on the caller and the name the route used (the client-supplied event_name on WebSocket/SSE, the function name on the HTTP API), so a method reachable under two names (an alias attribute, or a second name bound in mount) 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 builtin staticmethod/classmethod wrappers are unwrapped, and membership is compared with is. A handler whose identity cannot be determined this way (a property or other descriptor, a functools.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_limit now applies on the page-POST fallback. The per-handler @rate_limit was checked on WebSocket/SSE and on the HTTP API, but the page-POST fallback (mixins/request.post), which also runs an @event_handler from 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 answers 429 with code: "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 behind resolve_client_ip, so DJUST_TRUSTED_PROXY_COUNT still applies); the HTTP API's behaviour is unchanged. Tests: python/djust/tests/test_ratelimit_post_fallback.py.

  • Development dependency security: Update source-map-js to 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 embedded json.dumps output in an inline script with |safe, and json.dumps leaves <, > and & raw, so a registered preset or design-system value containing </script> closed the element. Both blobs now go through escape_json_for_script, the same helper the debug panel uses; the data the page's script reads is unchanged. 3 cases in python/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 "#" for javascript:, vbscript: and data: schemes, including whitespace and control-character obfuscation. Image sources retain data:image/*; links do not. Other schemes, including sms:, geo: and app deep links, remain unchanged. Developer-supplied button href= and passthrough URL attributes still raise ValueError. 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.md records 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 (a cp311-abi3 wheel 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.md was missing from docs/website/_config.yaml and 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.md came in with #3333 and was in the guides index but not in docs/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-input is no longer described as firing on every keystroke (#3356). The template cheatsheet, dj-paste, live_input, best-practices and docs/ai pages, llms-full.txt and the schema description (which feeds the generated AI-context files and djust_schema) now say what the client does: text-like fields are debounced 300 ms by default, range and color are throttled 150 ms, number 100 ms, checkbox, radio and select send immediately, and dj-debounce / dj-throttle override it (dj-debounce="0" sends every keystroke). The pins in python/djust/tests/test_schema_directive_pins_3327.py read 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 striptags intentionally differs from Django's strip_tags on 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.py re-derives every documented input, answer and count from the pinned fixture, so the page cannot drift from it. striptags behaviour is unchanged.

Deprecated

  • Dict-style access to block in a Python-bridged tag is deprecated (#2918). context["block"]["super"], block.get("super"), block.values(), block.items() and dict(block) (the shape of the dict the tag received before {{ block.super }} became lazy) still work, but each read now emits the standard djust DeprecationWarning, pointing at the tag's own line: deprecated since 1.3, removed no earlier than 2.0.0, use context["block"].super() instead. Each read still renders the parent once with no memoization, and the parent's own exception class reaches the tag's except unchanged. Django's own resolver (Variable("block.super"), {% blocktranslate with s=block.super %}) (and djust's own Rust resolver) is recognised and takes the super() method as in Django, with no warning, even when Variable._resolve_lookup is wrapped by a profiler or monkeypatch. Covered by python/tests/test_block_super_bridge_lazy_2918.py.

All releases · Atom feed