This is a pre-release. djust 1.2.1 has shipped since: read the djust 1.2.1 release notes.
Part of djust 1.2 — read the 1.2 release notes.
Before you upgrade, read Removed below.
Changed
- Behavior change: whitespace between inline siblings is now rendered in LiveView pages (#2999). It already was on plain Django pages. Inline and inline-block siblings written on separate lines or with a space between them — button rows,
<img>galleries, badges, nav links,<label> <input>— now get the usual gap of about one space unless their container is flex or grid. To keep them flush, make the containerdisplay: flex/grid, or wrap the markup in{% spaceless %}. Custom JS that walkschildNodes,firstChildornextSiblingbetween inline elements now meets a" "text node there. Patch paths count those nodes, soBugCapturepatch lists recorded by an older version are not path-comparable with new ones (state-driven replays are unaffected). Known gaps: whitespace at the very start or end of an inline element (Hello<span> <b>World</b></span>) is still dropped; a table's foster-parented whitespace is repaired by the mount morph rather than normalized up front; stream HTML passed tostream_to(html=…)/stream_insertis sent as given, so stream containers that the server also patches should bedj-update="ignore". - Faster full-parse renders: the fragment text map is built only when needed (#3013).
render_with_diffrebuilt the fragment→text-node map after every full parse, about 13% of the render on a 5,000-row block list whose renders never take the text fast path. The fast path now builds it on first use. Rust tests incrates/djust_live/src/lib.rs(fast_path_flag_tests). - A patch batch finds its
{% if %}markers with one DOM scan (#3014). EveryMoveSubtree,InsertSubtreeandRemoveSubtreein a batch scanned the whole document for its marker comment; a prepend to a 1,000-item{% for %}{% if %}list took about 2 s in jsdom._applyPatchBatchnow builds one marker map per batch. Covered intests/js/dj_if_marker_index_3014.test.js. djust_theming.E001is a Warning, not an Error (#3028). Thetheme_contextcontext processor is optional:{% theme_head %},{% theme_switcher %}and{% theme_panel %}work without it, and it only supplies the{{ theme_head }}-style variables. As an Error the check blockedmigrate,runserverand every other command. The id is unchanged, so existingSILENCED_SYSTEM_CHECKSentries keep matching.
Fixed
djust.C016flags a djust-firstTEMPLATESentry that lacks the admin's context processors (#2883). WithDjustTemplateBackendfirst, djust's engine renders the admin's templates, and withoutdjango.contrib.auth.context_processors.auththe admin index fails withKeyError: 'user'while/admin/login/still renders. Django's own admin checks look only atDjangoTemplatesentries. The installation guide no longer tells admin projects to avoid the djust-first order (#2872 is fixed). Covered intests/unit/test_scaffold_cli_config_v1_2_1_13.py.djust newprojects can mount djust's own LiveViews (#2889, part 1). The scaffold'sLIVEVIEW_ALLOWED_MODULESlisted only the project's app, and an explicit list replaces the default that admits"djust", so the component gallery, theme gallery and admin-extension pages rendered but never mounted. The scaffold now writes"djust"too, and the newdjust.V015check warns when the URLconf routes a djust LiveView that the allowlist rejects. Existing projects add"djust"themselves; always admitting it is planned for 1.3. Covered intests/unit/test_scaffold_cli_config_v1_2_1_13.py.{% verbatim %}inside a{% block %}of an extended template no longer breaks the LiveView render (#2890). Inheritance flattening wrote the verbatim body (for example{%) back as bare template source. The re-parse then read it as a tag, and the page failed with a misleading "Invalid block tag … expected 'endblock'". Text that would re-parse as template syntax now writes each{as{% templatetag openbrace %}. The output matches Django's. Tests inpython/djust/tests/test_rust_renderer_v1_2_1_9.py.A
dj-view/dj-rooton a<main>,<section>or<article>broke every patch (#2892). Finding the root and its closing tag only worked for<div>. For any other element the initial page skipped the normalisation the WebSocket render applies, the two renders described different trees, and each event fell back to re-rendering the whole region from recovery HTML. The root can now be any element inside<body>. The closing tag is matched by the root's own element name. A page that declares its root on<html>,<head>or<body>, or leaves the root unclosed, now logs one warning per view instead of degrading silently. Thedjust.V012check and the Rust text-node scanner use the same rule; the scanner had also treated<body dj-view-transitions>as the root. Covered inpython/djust/tests/test_root_detection_2892_2981.pyandcrates/djust_live/src/lib.rs.The signed back-navigation snapshot now restores component state (#2896).
_capture_snapshot_statestored component state under__components__, but_restore_snapshotdropped it, becausesafe_setattrrefuses dunder names. Both restore paths now use one helper,restore_components_snapshot, which time-travel's restore shares. It applies each entry only to a component the view registers or declares on its class, and sets values throughsafe_setattr. Tests intests/unit/test_view_state_v1_2_1_7.py.Text updates no longer show HTML entities such as
&(#2898). Both parse-skipping text fast paths put the raw, entity-escaped HTML into theSetTextpatch, which the client assigns totextContent. A value likea & b(plain, or|safeHTML whose first render had no entity) therefore appeared on the page asa & b. The fast paths now decode Django's escapes and numeric character references. They leave the body of an element the HTML parser keeps as raw text (<script>,<style>,<noscript>,<xmp>, …) undecoded, and fall back to the full parse for any other entity (for example ) or for such an element inside<svg>/<math>. Rust tests incrates/djust_live/src/lib.rs, plus real-patch fixtures intests/js/vdom_correctness_v1_2_1_8.test.js.is_dirty/changed_fieldsnow see a class-level component's state (#2912). A class-level component (ADR-031) lives in a_component_<name>slot, which dirty tracking skipped, so{% if is_dirty %}stayed quiet after a change to the component'sState. The component is now fingerprinted under its public name. A write of the same value still reads as clean. This is visible: a change that only touches a component'sState(switching aTabstab, for example) now makesis_dirtytrue and lists the component inchanged_fields. A view that treats component state as UI-only can callmark_clean()in that handler. Tests intests/unit/test_view_state_v1_2_1_7.py.A reused sticky child silently ignored changed
{% live_render %}kwargs (#2919, 1.2.1 part). A sticky child keeps its live instance across parent re-renders, so the tag's kwargs reachmount()only once. djust now logs a warning naming the kwargs that changed since the child was mounted, and the sticky LiveViews guide says kwargs are mount-time only. Behaviour is unchanged; re-applying changed kwargs is planned for 1.3. Covered inpython/djust/tests/test_runtime_lifecycle_v121_5.py.A component handler's
_skip_renderrendered anyway and leaked into the next event (#2924). Thecomponent_idevent route never consulted_skip_render, so a component handler that setself._view._skip_render = Truestill re-rendered, and the flag survived until the next view event, which then answerednoopand never showed its change. The route now resolves the flag through the same helper as every other route: it answersnoop, consumes the flag, and a forced full-HTML render still wins. Covered inpython/djust/tests/test_runtime_lifecycle_v121_5.py.A view whose
tick_intervalis shorter than its mount time now ticks (#2945). The tick loop starts during mount and stopped for good if its first beat came before the consumer had the mounted view. It now waits for the mount to finish. It also stops when a different view is mounted on the same socket, instead of ticking the new view a second time.Back-navigation state snapshots collided across query strings (#2949, 1.2.1 part). Snapshots, and the VDOM fast-paint cache, were keyed by pathname only, so
/orders?page=1and/orders?page=2shared an entry. They are now keyed by pathname plus query on capture and lookup.live_redirectalso read the page it was leaving afterpushState, so it named the destination and captured nothing; it now reads it first. Old cache entries simply miss once. The full key normalisation is part of 1.3. Covered intests/js/client-behaviour-v121-10.test.js.start_asyncwork queued from a tick,server_pushordb_notifyturn now runs (#2955).handle_tick,server_pushhandlers andhandle_inforun on the WebSocket consumer's own turns, and none of those turns started queued background work. Astart_asynccall there sat unrun until a later event happened to drain it, or never ran. Each turn now starts its queued work once its hook succeeds; a hook that raises starts nothing.is_dirty/changed_fieldsnow see fields declared withstate()(#2956). Astate()field keeps its value in a private_state_<name>slot, which dirty tracking skipped, so changing one never marked the view dirty. The fingerprint now reads these fields through the descriptor under their public name. Tests intests/unit/test_view_state_v1_2_1_7.py.PWA sync handlers registered with
@register_sync_handlerwere never called (#2957).sync_endpoint_viewcopied them under the bare model name, which the sync batch never looked up, so every action fell back to the default path, and a model's create, update and delete handlers overwrote each other. They are now registered under their full<action>_<model>key, and a handler registered for a whole model withSyncManager.register_sync_handler(model_name, fn)is used too (a full<action>_<model>key passed there still serves that action only; the two kinds are kept apart, so a client-supplied model name can't select an action handler). A handler that raises now returns "Sync handler failed" to the client and logs the exception, rather than returning its text. Covered inpython/djust/tests/test_uploads_v121_11.py.{% dj_activity %},{% colocated_hook %},{% live_form %},{% live_field %}and{% live_errors %}now work in root LiveView templates (#2958). They had no handler in the Rust renderer, so a root template that used them failed with "Invalid block tag". After{% load live_tags %}they now go through djust's template-library bridge: Django's own nodes render them, and the output is compared with the Django engine's in tests. A root template is rendered from a context that cannot carry the view, so the tags, and thefield_value/has_errorsfilters, fall back to the view being rendered (as{% live_render %}already does) when theviewthey are given is not one.dj_activitytherefore still registers the activity for event gating. The rest oflive_tagsis unchanged. The markup from{% live_form %}and{% live_field %}is still HTML-escaped, on the Django engine as well; that is a separate defect, #3043. Tests inpython/djust/tests/test_rust_renderer_v1_2_1_9.py.Legacy views no longer save
state()backing slots or_reactive_statein the private session (#2959). These are framework storage, not application private state. A publicstate()field's value already travels in the public state, and every restore path sets it back through the descriptor. A_-namedstate()field, or one instatic_assigns, is not part of the public state, so its slot is still saved. So is a slot whose value holds a Django model: the public path flattens a model to a dict, and the private one re-hydrates it. Sessions written by earlier versions still restore. Tests intests/unit/test_view_state_v1_2_1_7.py.The DEBUG SQL capture missed queries from sync event handlers (#2961). A sync handler runs on a worker thread with its own database connection, which had no capture wrapper. With
DEBUGon, the wrapper is now installed on that connection for the handler call, and the capture scope follows the event into the worker. Production handlers are unaffected. Covered inpython/djust/tests/test_runtime_lifecycle_v121_5.py.self.listen()inmount()never subscribed the view to NOTIFY (#2962). The WebSocket transport joined thedjust_db_notify_<channel>groups beforemount()ran, so only a class-level_listen_channelsworked andhandle_infonever fired for the documented pattern. The transport now joins every channel the view listens on aftermount()(or a session restore) and at the end of each event turn, solisten()also works from a later handler. Joins are idempotent. Covered inpython/djust/tests/test_runtime_lifecycle_v121_5.py.dj-loadingstates now last untilstart_async,@backgroundandassign_asyncwork finishes (#2963). Theasync_pendingflag that keeps a loading state on was computed from a legacy attribute nothing sets, so loading states ended at the event's first response. Background work that raises (even withouthandle_async_result()) or is cancelled withcancel_async()now still sends its result frame, so the loading state it started ends.stream(..., limit=)andstream_prune()never capped the page (#2964). They queued astream_pruneoperation that nothing delivered, so long lists kept growing. The prune now trims the stream's items on the server, and the next render removes the pruned rows through the normal diff. Covered inpython/djust/tests/test_client_behaviour_v121_10.py.push(page_loading=True)left the page-loading bar at 90% (#2965). The client calledpageLoading.stop(), which does not exist; it now callsfinish(). Four abortedlive_redirectpaths (an unsafe target, a cross-origin target, a non-LiveView target) had the same bug and now finish the bar too. Covered intests/js/client-behaviour-v121-10.test.js.manage.py generate_swcrashed on every run (#2967). Its--versionoption clashed with Django's own. The option is now--sw-version(python manage.py generate_sw --sw-version 2.1.0);--versioncould never be used, so nothing that worked changes.Presence users dropped out after about a minute (#2968). Presence entries expire after 60 seconds without a heartbeat, and the client never sent
presence_heartbeat. The server now refreshes the view's tracked presence on the client's 30-second connection ping, so users stay listed while the page is open. No client or wire change. Covered inpython/djust/tests/test_realtime_v121_6.py.Sticky unmount cancelled no background work (#2969). The default
_on_sticky_unmount()calledcancel_async_all(), which did not exist.AsyncWorkMixin.cancel_async_all()now drops every scheduledstart_asynctask and marks the running ones cancelled, so their re-render is skipped. While fixing this, the runtime's background-task path (which serves WebSocket events) was found to ignorecancel_async()for a task that had already started; it now honours it, as the older consumer path did, including the closingsource="async"frame that ends the event's loading state. Covered inpython/djust/tests/test_runtime_lifecycle_v121_5.py.clear_draft()from an event handler did not clear the open page's draft (#2971). The client readdata-draft-clearonly at page load, so a flag that arrived in a patch after a submit was ignored and the draft came back on the next load. Over a live connectionclear_draft()now also pushes adjust:draft-clearevent that clears the draft at once, and the flag is applied after every DOM update (which covers the HTTP fallback). On the server,clear_draft()re-arms on every call; a second call in a session used to be ignored. Covered intests/js/client-behaviour-v121-10.test.jsandpython/djust/tests/test_client_behaviour_v121_10.py.Resumable uploads could not resume after a WebSocket drop (#2972). The disconnect aborted every in-flight writer, and
ResumableUploadWriter's abort deleted the resume state. A disconnect now suspends a resumable upload instead: the state and the inner writer are kept for up to 10 minutes (at most 32 per process), and the owning session'supload_resumere-attaches it so the remaining chunks continue the same writer. When there is no suspended writer to continue (another process, an expired window, no matching upload slot), the reply is nownot_foundrather thanresumed, so the client starts over instead of sending chunks nothing accepts. Cancel and writer errors still abort and delete the state. Covered inpython/djust/tests/test_uploads_v121_11.py.FormMixin.reset_formis now an event handler, and calling it fromform_validworks (#2974).dj-click="reset_form"was rejected under strict event security, although the docs presented it as template-callable. Areset_form()insideform_validwas also undone, becausesubmit_formthen copied the submitted values back intoform_data. This makesreset_forma client-callable handler on everyFormMixinview; it only resets that session's form to the form class's initial values, and it ignores its arguments. The forms demo now resets before it sets its success message. Tests intests/unit/test_view_state_v1_2_1_7.py.A
dj-rootwith any other attribute never opened its WebSocket (#2981). The initial GET addeddj-viewonly to the exact text<div dj-root>, so<div dj-root class="search">,<div class="x" dj-root>or<section dj-root>got nodj-view. The client had nothing to mount and every event fell back to an HTTP POST, with no warning.dj-viewis now added to everydj-rootelement that doesn't declare one, however it is written.<div dj-root>still renders exactly as before. Only a realdj-rootattribute gets it: adj-rootthat appears inside an attribute value (for example user text invalue="…") is left alone. Covered inpython/djust/tests/test_root_detection_2892_2981.py.djust deploy <slug> --from-gitworks (#2982). Only the flag-first form parsed; the formdjust deploy --helpdocuments failed with "No such option '--from-git'". The flag is now recognised in any position. Covered intests/unit/test_scaffold_cli_config_v1_2_1_13.py.The deploy doctor no longer warns about sqlite on every
djust newproject (#2983). The scaffold reads the sqliteNAMEfromDJUST_SQLITE_PATH, the writable path the host injects, but the doctor warned on any sqlite engine. It now stays quiet when theNAMEinDATABASESreads the environment. Covered intests/unit/test_scaffold_cli_config_v1_2_1_13.py.LIVEVIEW_CONFIG['jit_serialization'] = Falseturns JIT serialization off (#2984, part 1). Nothing read the key. Models, QuerySets and model lists then take the non-JIT fallback. The six other keys that djust defines but never reads (jit_cache_backend,jit_cache_dir,jit_redis_url,debug_components,component_wrapper_class,component_loading_class) are deprecated: the newdjust.C018check warns when one is set, and 1.3 removes them. Covered intests/unit/test_scaffold_cli_config_v1_2_1_13.py.False
[dj-hook] No hook registeredwarnings forCountdown,InfiniteScroll,ScrollSpyandMarkdownTextarea(#2985, 1.2.1 part). Their scripts initialise themselves and never registered thedj-hooktheir markup carries, so the browser logged a warning for components that worked. Each script now registers the hook, and itsmounted()runs the same guarded init, so nothing is initialised twice. An app's own hook of the same name, inwindow.djust.hooksorwindow.DjustHooks, is kept. The other components whosedj-hookno shipped script implements are unchanged (1.3). Covered intests/js/component_script_hooks_2985.test.js.The CSRF meta tags and the debug-panel CSS could land outside the page's
<head>(#2987). They were inserted before the first (meta) or every (CSS)</head>string in the page, including one inside an inline<script>string or a comment; the real head then lacked the meta tags andwindow.djust.csrfToken()returned''underCSRF_COOKIE_HTTPONLY/CSRF_USE_SESSIONS. Both now go into the document's real<head>once, found on a copy of the page with script, style, comment, title and textarea contents masked. The client scripts are placed before the real</body>the same way, instead of before every</body>string. Tests intests/unit/test_csrf_meta_injection.py.Button labels and empty rating stars failed contrast on bright themes (#2996, parts 1–2).
.dj-btn's label fell back to a literalwhitewhile its background followed--primary, which gave 2.3–2.7:1 on bright primaries..dj-btn,.dj-btn-danger,.dj-btn-success,.dj-notification-badge,.dj-notif-popover__badgeand the primary / success / danger.dj-ribbonlabels now fall back to the theme's paired--primary-foreground/--destructive-foreground/--success-foreground. The--dj-btn-*-fg/--dj-notification-badge-fg/--dj-ribbon-fgoverrides still win, and with no theme loaded the label is still white. The optionaldjust_theming/css/scaffold.cssdoes the same for.btn-success,.btn-danger,.card-header-successand.badge-primary..rating-star-empty(and the hover preview) went fromhsl(var(--muted-foreground) / 0.3), about 2:1, to full--muted-foreground. That reaches 3:1 (WCAG 1.4.11) on the background and on cards for every preset whose muted-foreground/background pair passes AA. Recolouring thedjustbrand preset is left for 1.3 (#2885). Covered inpython/djust/components/tests/test_components_css_2996_2993_3008.py.Regression test: keyed lists (
dj-key) stay in server order after filter-and-restore and re-sort-and-restore (#2997). This covers the djust.org/themes/flow: 69 items filtered to 6 and restored, re-sorted and restored, and filtered straight into the re-sort. The single patch-batch placement model from #3009 already fixes it. Real-patch fixture intests/js/vdom_correctness_v1_2_1_8.test.js.{% csrf_token %}forms rendered over the WebSocket failed with "CSRF verification failed" (403) (#2998). The socket mount path rebuilt the view's request withRequestFactory, carrying the session and user but no cookies, soget_token()minted a new secret the browser never received. A form on any page reached withdj-navigate, or on a re-mounted view, posted a token that did not match the browser's CSRF cookie. Every rebuilt request now binds the browser's CSRF cookie from the socket scope and runs Django's ownCsrfViewMiddleware.process_requeston it, soCSRF_COOKIE_NAME, legacy masked cookies andCSRF_USE_SESSIONSbehave exactly as on HTTP. This covers WebSocket mounts (includingmount_batchandlive_redirectmounts), the real SSE stream request, and the request that sticky children are re-stamped with onlive_redirect. Projects that worked around this by binding the cookie inmount()can drop the workaround. 18 regression cases inpython/djust/tests/test_csrf_socket_render_2998.py, including a CSRF-enforcingClientthat POSTs the socket-rendered token and gets 302.Words run together in LiveView pages:
<b>A</b> <i>B</i>rendered as "AB" (#2999). The VDOM parser dropped every whitespace-only text node, and the egress normalizer collapsed every> <to><, so the space between two inline elements vanished fromrender_with_diff()and the WS frame (e.g.**Lead.** \code`in rendered Markdown read "Lead.code"). Both now keep whitespace between two inline-level siblings (custom elements count as inline) as a single" "text node and still drop indentation between block-level siblings; the normalizer's decision now runs in Rust next to the parser's rule, and is faster than before. The client counts a text node that is exactly" "` when it resolves patch paths, so server and client agree on child indices.Applying a patch batch was reworked to one model, shared by the client and the server's reference
apply_patches: everyRemoveChildis resolved against the DOM as it was before the batch (an id-less one used to remove the wrong node after aRemoveSubtreeshifted the list), and a parent's inserts, moves and{% if %}span inserts/moves are placed together by final index (moves used to run one at a time against the live list, so a forward keyed move landed one slot early —[a,b,c]→[b,c,a]came out "bac" — and inserts landed before the boundaries they belonged next to had moved). Also fixed: the text fast path kept patching a text node that a full parse had removed (an error message that cleared and came back stayed empty), and a loop-cache placeholder (<dj-pc-…>) could reach the page on a partial render after a render with cache hits. Regression tests:python/djust/tests/test_inline_whitespace_2999.py,python/djust/tests/test_inline_whitespace_fastpath_2999.py,crates/djust_vdom/tests/test_inline_whitespace_2999.rs,tests/js/inline_whitespace_parity_2999.test.js.A WebSocket closed while an event was in flight now always reaches
disconnect()(#3000). The event's reply hit a socket the client had already closed. uvicorn raisesClientDisconnectedthere (anOSError, as ASGI 2.4 specifies), which the consumer did not expect; the error escaped Channels' dispatch loop, so the pending disconnect was never handled. Presence, channel groups and the tick task outlived the socket, and the tick kept the presence record alive indefinitely: a zombie session, with nothing logged. The consumer now drops sends to a peer-closed socket, runsdisconnect()if its dispatch loop ever dies before the disconnect is handled, and stops a tick loop once the socket is gone.A
server_pushthat arrives while the session is busy is now delivered (#3001). A push that found a user event or background result in progress, or the render lock held, was dropped, so the last push of a change (a game's final frame) could leave a viewer on stale state indefinitely. It is now queued; once the session is free, every queued push is applied in order and the view renders once. Identical queued pushes are applied once, the queue is bounded, and pushes queued for a view that has since been replaced are discarded.djust.V004no longer flagshandle_*methods (#3002, 1.2.1 part). An undecoratedhandle_*method is how a handler is made callable byserver_pushbut not by browsers, and both of V004's hints (add@event_handler, or prefix_) broke that. Other handler-like prefixes (on_*,toggle_*,submit_*, …) are still flagged. An explicit@push_handlermarker is planned for 1.3. Covered inpython/tests/test_checks.py.The update check stops flagging a patched release within an hour of an advisory fix (#3006). The advisory list was cached for 24 hours, so after an advisory's range was corrected,
djust.U001and the dev-server notice kept telling developers to upgrade a release that was no longer affected. A cached advisory that matches the installed version is now re-fetched after an hour by the dev server and the CLI.U001itself never fetches, so its hint names the cache file to delete. Covered intests/unit/test_scaffold_cli_config_v1_2_1_13.py.The lifecycle schema advertised view hooks djust never calls (#3007, 1.2.1 part).
connected(),disconnected()andunmount()are removed fromdjust.schema.LIFECYCLE_METHODS(read bydjust_ai_contextand tools); no LiveView method by those names is ever called. The LiveView API reference now shows how to tell the live mount from the HTTP render. Real hooks are planned for 1.3. Covered inpython/djust/tests/test_realtime_v121_6.py.CodeBlock's Copy button did nothing (#3008). It rendered a bare<button class="code-block-copy">with no handler. It now carriesdj-copy="#<id>"pointing at its own<code>element (a per-instance id, or the component's explicitid=when that is a plain CSS identifier), plustype="button",aria-labelanddj-copy-feedback, so it copies the raw source. Class names are unchanged.dj-copyis bound by the LiveView client, so the button works on LiveView pages. The{% code_block %}tag keeps its own inline handler. Covered inpython/djust/components/tests/test_components_css_2996_2993_3008.py.Text patches inside
<pre>,<code>and<textarea>no longer fail with "node not found" (#3012). The server keeps every text node directly inside these elements, including whitespace-only ones, but the client's path walker skipped them, so aSetTextwhose path crossed one was lost. The client now follows the server's rule: every text child counts when its DIRECT parent ispre,code,textarea,scriptorstyle. Real-patch fixtures intests/js/vdom_correctness_v1_2_1_8.test.js.The handler-metadata script is injected once, before the page's real
</body>(#3018)._inject_handler_metadatausedhtml.replace("</body>", …), which inserted the script before every</body>string, including one inside an inline script, a comment or a<textarea>. It now uses the masked lookup #3017 added for the CSRF meta and the client scripts, with the page's real</html>as the fallback. Output is unchanged for a page with a single</body>.The raw-text masker is linear on malformed markup (#3019). Its
<script\b[^>]*>/</script[^>]*>patterns scanned to the end of the page from every<scriptor</scriptwith no>after it, so a page with many of them (only reachable through|safe/mark_safeoutput) took seconds on every render. The tag patterns now stop at the next<, as #3017's do. The streaming split (_split_for_streaming) had its own mask of the same shape (22 s on 32 000 unclosed<script>tags) and now uses the shared one, which also masks comments and<style>bodies.T018 no longer reports string literals or filter names in
{% if %}(#3020).{% elif app.status == 'deploying' %}reported'deploying'as an undefined variable, and{% if items|length > 0 %}reportedlength: djustlive got 92 such notices on upgrading to 1.2.0. Theif/elif/whilescan now skips quoted string literals, numbers and filter names. A variable filter argument (|default:fallback) is still checked.Third-party inclusion tags work in LiveView templates on projects without the djust template backend (#3024). Django's
InclusionNodegot theDjangoTemplatesbackend's template wrapper, whoserender()accepts only a dict, and raised "context must be a dict rather than Context". The bridge now hands it the engine-level template, as Django's own engine would. Output is compared with Django's inpython/tests/test_inclusion_tag_django_backend_3024.py.Highlighted shell snippets keep the spaces between words (#3026).
highlight_code("pip install djust", "bash")wrapped each space in its ownhl-wspan between two bare words, and the VDOM dropped those whitespace-only nodes: the page (and the Copy button) showedpipinstalldjust. The span is now unwrapped into the following text, as #3008's fix already did before a token span.A view whose
mount()raises no longer keeps ticking (#3027). The tick task starts beforemount(); whenmount(),handle_params(), the actor mount or the initial render raised, the error frame went out but the tick kept callinghandle_tickon the half-mounted view every beat until the socket closed. The runtime now reports the failure to the transport, which cancels that view's tick. 3 regression cases inpython/djust/tests/test_mount_failure_stops_tick_3027.py.The Python and Rust root locators agree when a quoted attribute value contains markup (#3030). Python searched the whole page with a regex that could start inside
<div data-h="<section dj-root>">and pick the<section>in the value, then stampdj-viewinside it; the Rust locator skips quoted values. Python now walks the page tag by tag the way the Rust locator does. Both treat<as a tag start only before a letter,/or!, and the Rust closing-tag walk does too, so<main dj-root>a < b</main>is found.Live navigation updates the tab title (#3036).
dj-navigate/live_redirectswaps only thedj-root, so the tab kept the previous page's<title>unless the view setpage_title. The destination's<title>({% block title %}included) is now rendered with the view's values and sent as apage_metadatatitle; a view's ownpage_titlestill wins, and a title that uses{{ block.super }}or a context-processor variable is left alone. The navigation guide now lists what live navigation updates and what it keeps (<head>assets and outside-root scripts; diffing those is planned for 1.3). 14 regression cases inpython/djust/tests/test_live_redirect_title_3036.py.The HTTP-POST fallback honours
_skip_render(#3038). A view orcomponent_idhandler that setself._skip_render = Truestill got a full render over the HTTP fallback, and the flag was never reset. The decision now goes through the same_resolve_skip_renderthe WebSocket and SSE routes use: the response is an empty patch list with noversion, still carrying flash and page-metadata commands (but, like the WebSocket noop, never stored as an@cachehit), andset_changed_keys()still forces a render. 5 regression cases inpython/djust/tests/test_http_skip_render_3038.py.dj-offline-hide,dj-offline-showanddj-offline-disablework (#3041). Their CSS keys onbody.djust-online/body.djust-offline, and nothing set those classes, sodj-offline-hideelements were always hidden,dj-offline-disableelements always disabled anddj-offline-showelements always shown, online or not. The client now sets the class at page load fromnavigator.onLineand updates it on the browser'sonline/offlineevents. 4 regression cases intests/js/offline-body-classes-3041.test.js.{% live_form %},{% live_field %}and{% live_errors %}render the form instead of its escaped markup (#3043). The tags returned a plainstr, so Django escaped it and the page showed<div class="mb-3"><label …as text, on both template engines.FormMixin.as_live()/as_live_field()now return aSafeString, andlive_errorsbuilds its markup withformat_html. Every value a user can influence (field values, error messages, choice labels and values) stays escaped; the adapters now also escape the labelfor=ids and thewrapper_classargument. CustomFrameworkAdapters must escape the values they interpolate, as the built-in ones do.{% live_input %},{% djust_skeleton %}and{% djust_track_static %}work in root LiveView templates (#3044). They had no Rust handler, so a template using them failed with "Invalid block tag", as #2958 was for five otherlive_tags. They now bridge through Django's own nodes. djust's own bridged tags share one DjangoRenderContextper render, so{% djust_skeleton %}emits its<style>block once per render, as on the Django engine; third-party tags keep a fresh context per call. Output is compared with Django's inpython/djust/tests/test_live_tags_bridged_3044.py.Text inside
<noscript>,<xmp>,<iframe>,<noembed>,<noframes>and<plaintext>is no longer escaped twice (#3045). The parser keeps their bodies raw, but the VDOM serializer escaped every text node outside<script>and<style>, so<noscript>Tom & Jerry</noscript>reached the client asTom &amp; Jerry. The serializer and the text fast path now share one raw-text element list (djust_core::raw_text).The offline indicator and the offline banner show when offline, and the indicator's text and class follow the network state (#3051).
{% djust_offline_indicator show_when="offline" %}andpython/djust/templates/djust/pwa/offline_banner.htmlcarried an inlinedisplay: nonethat nothing removed, so neither ever appeared; and no client code read the indicator'sdata-online-text/data-offline-text/data-online-class/data-offline-class, soshow_when="always"always read "Online" with no status class. Visibility is now thedj-offline-showCSS on thebody.djust-offlineclass the client sets (#3041), and the client swaps the indicator's text anddjust-status-*class on every change. Thedj-offline-showrule from{% djust_pwa_head %}/{% djust_offline_styles %}also hides elements until the body is marked offline, so they no longer flash before the client runs; on a page that includes that CSS but never loads djust's client they now stay hidden (they were always visible), asdj-offline-hideelements already did. The indicator is rendered with the status class of its initial state; class names, data attributes and tag arguments are unchanged. 7 cases intests/js/offline-indicator-3051.test.js, 12 intests/unit/test_pwa_offline_indicator_3051.py.React component props are serialised as real JSON and fully escaped. A capitalised component tag such as
<Greeting who="{{ name }}" />wrote its props intodata-react-propswith only"escaped, so a value with an apostrophe, backslash or control character produced JSON the client could not parse. The template renderer, the hydration pass andReactComponentRegistry.render()now build the JSON with a real encoder and entity-escape the attribute value; readers decode it as before (dataset.reactProps). The hydration pass no longer re-resolves{{ var }}inside prop values the renderer has already resolved, so a context value that itself reads{{ other }}stays literal. Entities in literal props now reach the client as written:label="Tom &amp; Jerry"arrives asTom &amp; Jerry, where it used to arrive decoded once.
Security
- The log-injection filter now covers every framework logger (#2947).
DjustLogSanitizerFilterwas attached to thedjustlogger only, and Python runs a logger's filters only for records logged on that logger, so records fromdjust.websocket,djust.runtimeand the other child loggers reached handlers with CR/LF and control characters intact.DjustConfig.ready()now attaches the filter todjustand everydjust.*logger, including ones created later. Framework log lines whose string arguments carried newlines or ran past 500 characters now appear flattened and truncated, as the filter always intended. Covered intests/unit/test_log_sanitizer_child_loggers_2947.py. - Tenant-scoped presence works under the documented settings (#2973, part 1).
DJUST_CONFIG['PRESENCE_BACKEND'] = 'tenant_redis', the valuedjust.tenantsdocuments, silently selected the per-process memory backend forPresenceMixinviews;tenant_redisandtenant_memoryare now accepted. A view listingPresenceMixinbeforeTenantMixinlost thetenant:<id>:prefix on its presence key and shared presence groups across tenants; the prefix now applies in either base order. An unknown value logs a warning and the newdjust.C019check flags it. Upgrade note:tenant_redisnow really uses Redis, so it needs theredispackage and a reachable server, asredisdoes; a deployment that relied on the silent memory fallback should switch totenant_memory. Tenant-keyed view state (part 2) is planned for 1.3. Covered intests/unit/test_tenant_presence_2973.py. - Component and private-state restores are screened (#3046). A
__components__snapshot entry named like a component method (render,mount, a handler) is skipped instead of shadowing it, and_restore_private_stateskipsDANGEROUS_ATTRIBUTESand dunder keys instead of a rawsetattr. Both inputs are server-signed or session-sourced, so this is hardening, not a fix for an exploitable path. 4 regression cases inpython/djust/tests/test_restore_hardening_3046.py. - autobahn advisory GHSA-hxp9-w8x3-p566 (permessage-deflate bypasses
maxMessagePayloadSizeafter inflation): what it means for djust. djust depends onchannels[daphne], and daphne depends on autobahn. On Python 3.11 and later the lock already resolves autobahn 26.7.1, the patched release. On Python 3.10 it resolves 24.4.2, because every autobahn release from 25.9 on requires Python 3.11, so no patched version installs there. A default djust deployment is not exposed: daphne never enables permessage-deflate, and autobahn's server rejects every compression offer unless the application turns it on. If you run daphne on Python 3.10 and enable WebSocket compression yourself, move to Python 3.11 or serve with uvicorn. - Bandit now reports
pickle(B301),exec(B102) and weak hashes (B324) in shipped code. These rules were skipped repo-wide in the pre-commit hook, the CI Bandit step and the pre-release audit; only the reviewedmark_saferules (B703, B308) are skipped now, and a test keeps the three lists in step. The remaining intentional uses are marked: the serializer code generator'sexeccarries# nosec B102, and it now rejects any path segment, model name or function name that is not a Python identifier before building code. The three cache-key hashes passusedforsecurity=False. The audit's advisory Bandit log now applies the same excludes and skips, so it lists only findings that need a decision. - Publishing to PyPI is now gated on the pre-release security audit, and every scanner in it blocks. The audit used to run alongside
release.ymlon the tag push, so a release published even when the audit failed — and only Bandit and ESLint could fail it anyway. It is now a reusable workflow thatrelease.ymlandpublish.ymlcall as a job their GitHub Release and PyPI jobsneed. pip-audit (over every package pinned inuv.lock), cargo-audit (vulnerabilities and unsound advisories),npm audit(high/critical), clippy's deny lints and CodeQL (open high/critical alerts on the tag) now fail the audit, each against a reviewed allowlist (.github/security/pip-audit-ignore.txt,.cargo/audit.toml, the CodeQL config); Safety is removed in favour of pip-audit. The locked dependencies are refreshed to clear what the gates found: anyio 4.14.2, autobahn 26.7.1 (Python 3.11+), click 8.3.3 and Django 5.2.17 inuv.lock, anyhow 1.0.104 inCargo.lock, and brace-expansion 5.0.12 (dev-only) inpackage-lock.json. SeeRELEASING.md.
Documentation
- SECURITY.md documents both vulnerability intake channels and an advisory publication runbook (#2878). GitHub Private Vulnerability Reporting is listed (and preferred) next to security@djust.org. The runbook says where each channel's records live, requires every advisory credit to be checked against the original submission, and warns that editing
creditsthrough the API can clearcollaborating_users, which is an access grant. Alert,ProgressandAvatarare documented as unstyled (#2993, docs part). No stylesheet djust ships has a rule for the markup thesedjust.componentsclasses render. Their docstrings, their catalogue pages (/theme/components/alert/etc.) and the components guide now say so, list the classes to style, and point to the styled{% theme_alert %}/{% theme_progress %}/{% theme_avatar %}tags. The--dj-alert-*/--dj-progress-*names in the docstrings are now described as suggestions for your own stylesheet: nothing djust ships reads them. Shipping a default stylesheet is a 1.3 item.@rate_limitrejections count toward the 4429 disconnect (#3003, 1.2.1 part). The decorator's docstring and the best-practices guide now say that each rejected event adds a warning to the connection, and that atmax_warnings(default 3) djust closes the socket with code 4429 and starts an IP cooldown, so it is an abuse control rather than a UI throttle. A drop-only mode is planned for 1.3.{% badge %}is documented as shipping no CSS (#3025, 1.2.1 part). No stylesheet djust ships styles itsdj-badge--<status>,dj-badge__dot,dj-badge__dot--pulseanddj-badge__labelclasses, so a badge renders as plain text until you style it. The tag's docstring and the components guide now say so, list the classes, and point to the themed{% theme_badge %}. Shipping styles for{% badge %}is a 1.3 item.- Release notes for djust 1.2 and 1.1.4.
docs/website/releases/1.2.mdgathers the1.2.0rc1to1.2.0sections into one page for anyone upgrading from 1.1: what's new, the backwards incompatible changes with what to do about each, the security advisories and an upgrade checklist.docs/website/releases/1.1.4.mdcovers the 1.1.4 maintenance release. Both are listed under a new "Release notes" group indocs/website/_config.yaml.
Removed
djust.optimization.SerializerCacheis removed. It was a persistent serializer cache (filesystem or Redis) that loaded entries withpickle, but nothing in djust used it, and the serializers it was meant to hold cannot be pickled, so it could only ever load data someone else had placed in its cache directory or Redis key. Compiled serializers are cached in-process, as before. If you imported it, remove the import; there is no replacement.