The sixth 1.3 release candidate. It adds MarkdownEditor table actions and selection/empty-line menus (#3107, #3108), and it finishes rounds 4 to 6 of the open-issue drain (#3204).
- Security hardening: a reconnect no longer restores the pre-login session key and connection ids into a legacy
enable_state_snapshotview's state (#3248). The failure mode was fail-closed, and no data was shown to cross between users. See Security below. - MarkdownEditor: table editing that stays valid GFM, an Insert table action, bubble and floating menus, and
window.djust.getHook()(#3107, #3108). - View teardown: a replaced, redirected or re-mounted view, together with its embedded children and
wait_for_eventwaiters, is now torn down on every path. This covers WebSocket and SSE (#3236, #3239, #3244, #3245, #3232), and actor shutdown now waits for the actor to stop (#3228). - State persistence: refreshed signed snapshot tokens on component events and skip-render noops (#3231, #3237, #3246), and a late save that no longer writes into a session that has since logged out (#3247). Streaming terminal ops no longer overtake a queued update (#3227).
- Checks and theming: T002 and T012 agree on templates that never connect, and they read real attributes (#3225). A plain
.btnis readable in every design system (#3230).
Behaviour changes to check when upgrading from rc5:
- The MarkdownEditor selection (bubble) menu is on by default in Visual mode. Pass
bubble_menu=Falseto keep the old behaviour (#3108). - T012 warns on more templates, because its trigger set now follows the client's directive table, and T002 no longer fires on templates without
dj-view(#3225). - An app's own unlayered
a { color: … }rule now colours a plain<a class="btn">(#3230).
Added
- MarkdownEditor table actions, and a public way to reach a hook instance (#3107). In Visual mode a labelled Table group appears while the selection is inside a table: add a row above/below, delete the row, add a column left/right, delete the column, make the first row the header, and delete the table. The buttons use the toolbar's
data-markdown-actioncontract, witharia-labels anddisabledstate fromeditor.can(). Insert table is a new toolbar action in both modes (in Markdown mode, a GFM skeleton after the current line). Every edit stays valid GFM, enforced by the editor rather than by the buttons: a table cell's schema is a single paragraph, so shortcuts, input rules, pasted blocks,getEditor()commands and selections spanning a table can no longer put a heading, list, quote, code block, rule or nested table in a cell. Enter in a cell inserts a line break, a typed---stays literal, pasted blocks become text joined by line breaks (as do a Docs/Word paste mixing text and a table, and a drop, which lands in the cell under the pointer), copied cells or a spreadsheet table on its own still paste cell by cell, an HTML table whose cells hold several paragraphs keeps one cell per<td>, and an image in a cell (fromin Markdown, or an<img>in a pasted, dropped or loaded HTML cell) is an inline node, so typing or agetEditor()insert beside it keeps it. Before, a Markdown cell image was a block node inside the cell's paragraph: the next keystroke in that cell deleted it, and inserting next to it threwCalled contentMatchAt on a node with invalid content.getEditor()block inserts into a cell are refused (documented). Also fixed on the table path: a|typed in a cell is now saved as\|(it used to split the cell on the next load and lose the following cell's text); a line break inside a cell (saved as<br>) now reopens in Visual mode instead of forcing Markdown mode; and a table is written with one blank line around it instead of two (inside a list item it keeps its padding, so the item stays loose in previews).window.djust.getHook(el | selector)returns the mounted hook instance ornull(declared indjust.d.ts), and the MarkdownEditor hook'sgetEditor()returns the live Tiptap editor. See "Tables" and "Reaching the editor from app code" in the Markdown editor guide, and "Reaching a hook from page code" in the hooks guide. Tests:tests/js/markdown_editor_tables_menus_3107_3108.test.js,tests/js/hooks.test.js(getHook (#3107)) andjs/vendor/test/visual.check.mjs. - MarkdownEditor selection (bubble) and empty-line (floating) menus (#3108).
MarkdownEditor(..., bubble_menu=True, floating_menu=False)— also on the{% markdown_editor %}tag in both template engines and on themarkdown_controls.htmlinclude. Selecting text in Visual mode opens a formatting menu (bold, italic, code, link, plus the table actions inside a table);floating_menu=Trueadds a block menu on an empty line. Menu buttons keep the selection (mousedown is prevented), the browser's context menu is never suppressed (spelling suggestions stay available), and the menus are ARIA toolbars reachable with Alt+F10, arrow keys and Escape. The selection menu flips below the selection rather than cover the toolbar, and follows (or hides during) the editor's own scrolling.markdown-visual.jsnow bundles@tiptap/extension-bubble-menuand@tiptap/extension-floating-menuat 3.31.3, the same exact pin as the other@tiptappackages, plus their MIT dependency@floating-ui/dom(withcoreandutils); the manifest, license file anddjust.cdx.jsonare regenerated. The bundle grows from 157,918 to 170,432 bytes (gzip -9). Tests:python/tests/test_markdown_editor_menus_3108.py,tests/js/markdown_editor_tables_menus_3107_3108.test.jsandjs/vendor/test/build.check.mjs.
Changed
- MarkdownEditor: the selection (bubble) menu is on by default in Visual mode, for existing editors too (#3108). Selecting text in a Visual-mode editor now shows a small formatting menu next to the selection. It never replaces the browser's context menu. To keep the previous behaviour, pass
bubble_menu=FalsetoMarkdownEditor(...)or the{% markdown_editor %}tag,bubble_menu=Falseto themarkdown_controls.htmlinclude, or putdata-bubble-menu="false"on a hand-written host. An explicitdata-actionslist also filters the menu, and a menu it leaves empty is not shown.
Fixed
- The T002 and T012 system checks now agree about a LiveView template that never connects, and T012 reads real attributes rather than text (#3225). A template with
dj-*directives and neitherdj-rootnordj-viewrenders as a static page: djust stamps nodj-viewand the client mounts only[dj-view].- T002 fired on such a template with "This is OK — dj-root is auto-inferred from dj-view", although nothing is inferred without
dj-view. It now fires only when a template's markup hasdj-viewand the page has nodj-root, and its hint shows<div dj-root>. - T012's triggers: it knew ten event names, so a template driven only by
dj-viewport-bottom,dj-model,dj-poll,dj-uploadand others got no warning. Its trigger set is now derived from the client's directive table (djust._template_bindings.DIRECTIVES) plusdj-model,dj-uploadanddj-upload-drop, and it matches modifier suffixes such asdj-keydown.enter.dj-hook,dj-updateanddj-stream-modework without a connection and do not trigger it. - A root that wasn't there: the
dj-viewtest was a bare substring, sodj-viewport-bottomitself counted as adj-viewand silenced T012. - How both checks now read a template: through
_template_bindings' flattener, with the project's template engine. Only real attributes of real elements count, so prose such as<code>dj-click="save"</code>neither triggers nor satisfies either check. The root may come from a parent the template{% extends %}. A child whose parent cannot be loaded is skipped by both checks alike. - Templates T012 skips: a partial another template
{% include %}s (the includer is checked with it inlined; an include inside a comment, or a template including itself, does not count), the template of a view a{% live_render "dotted.View" %}embeds (a variable path is not followed), and component templates, identified by a realdj-componentordata-component-idattribute. - Cost: each parent and include is loaded and parsed once per check run, however many pages share it, and the checked template itself is lexed rather than compiled. On a synthetic 1500-page tree with shared includes the check takes about 0.46 s, against 0.15 s for the old text scan; without the per-run cache it took 13.9 s.
- Tests: new cases in
python/tests/test_checks_t002_t012_3225.py. They include a drift test that scans the client source and fails when the client reads an attribute T012 has not classified, or when a classified one is stale.
- T002 fired on such a template with "This is OK — dj-root is auto-inferred from dj-view", although nothing is inferred without
stream_done()andstream_error()no longer overtake a queuedstream_to()update (#3227).stream_to()andstream_text()queue an op that lands inside the ~60 fps rate window and send it later from a flush task, but the terminal ops were sent directly, so the client could receivestart, replace, …, done, replace: the final content arrived on a stream it had already finalised. Both terminal ops now send that stream's queued ops first. If the flush task is already sending its batch, they wait for it; if it is still waiting and nothing else is queued, it is cancelled; another stream's queued op is left to the flush. An update that goes out at once (the rate window has passed) now also sends the stream's still-queued update first, and is queued instead while the flush task is sending, so a stale update can no longer arrive after the settle and beforedone(the flush deadline had passed but the task had not run yet, for example after synchronous work that did not yield). Two related flush fixes: the flush takes its batch before sending, so an op queued while it sends is no longer lost to theclear()that followed (or to a "dictionary changed size during iteration" error), and a flush that finds ops queued behind it schedules a successor. Theasyncio.sleep(2 * MIN_STREAM_INTERVAL_S)workaround is removed from the streaming AI tutorial. New cases inpython/djust/tests/test_stream_terminal_flush_3227.py.- Actor shutdown now waits for the actor to stop, and actors release Python objects at once instead of leaving them in pyo3's deferred-decref pool (#3228, #3222).
ViewActorHandle::shutdown,ComponentActorHandle::shutdownandSessionActorHandle::shutdown(and so the PythonSessionActorHandle.shutdown()coroutine) used to send a message and return. They now return only after the actor's loop has ended and everything it owned has been dropped, so a faileduse_actors=Truemount no longer returns its error while the actor still holds the view. Unmounting a view and removing a component do not wait (they run inside the session or view actor's own loop, where waiting would hold every other message behind the teardown, or deadlock against a child forwarding to its parent); the stopped actor still releases its Python objects immediately. The actors ran on tokio workers not attached to the interpreter, where pyo3 cannot decref aPy<...>: it queued the decref until the next pyo3 entry on any thread, so a released view, contract module or LiveComponent instance stayed alive, with no Python referrer, for an unbounded time. Every actor-owned Python object (on teardown, when replaced by a newSetPythonView/SetPythonComponent, on a failed mount or component creation, and inside messages still queued when an actor stops) is now dropped with the interpreter attached. A session whose handles are all dropped without an explicit shutdown now shuts its views down too; they used to keep running, each holding its view. A view closes its queue before waiting on its child components, so a child forwarding an event to a full parent queue cannot deadlock the teardown.test_actor_parameter_contracts.pydrops the pyo3-entry workaround #3226 added: a failed[discovery]mount's view is freed by a singlegc.collect(). New cases in therelease_3228modules ofcrates/djust_live/src/actors/view.rs,crates/djust_live/src/actors/component.rsandcrates/djust_live/src/actors/session.rs. - A plain
.btn(no variant class) is readable whendjust_components/components.cssis loaded, hovered and at rest, in both modes (#3230).- The colour: that stylesheet's
.btnsetcolor: hsl(var(--foreground))outside any cascade layer. It therefore beat the theme's@layer components.btn:hovercolour whatever the specificity, while the theme's hover background still applied. In the bauhaus, neo_brutalist, retro, retro_computing and swiss design systems, a hovered plain button painted its text in its own background colour. - The face: it also set no background, so a
<button class="btn">kept the browser's lightbuttonfaceunder the light dark-mode text. Headless Chrome measured 59 of the other design systems at a 1.10:1 contrast ratio in dark mode. - The fix: only the paint moves into
@layer components, the layer the theme uses: the.btncolour, and a zero-specificity:where(button.btn, input.btn)face ofhsl(var(--muted)). A design system's own.btnbackground still wins, and an<a class="btn">keeps its transparent background. A guard,html a.btn:where(:hover), sits above the theme's layereda:hoverand below its.btn:hover, so a hovered link-button stays button-coloured rather than turning link-coloured. - Unchanged: the layout and typography of
.btn(display, padding, radius, font, border,text-decoration) stay unlayered as before. An unlayered reset such as Tailwind's preflight, or the theming package's own.btn, therefore still does not take over the box model. The.btn-*variants are unchanged. - One visible difference: an app's own unlayered
a { color: … }rule now colours a plain<a class="btn">, because unlayered styles outrank any layer. - Not reached: under Tailwind v3's preflight, whose unlayered
a { color: inherit }outranks every layered theme rule, a hovered plain<a class="btn">in the inverted designs is still unreadable, exactly as before this fix. - Tests: new cases in
test_theme_button_hover_contrast_3209.pyresolve the cascade over the stylesheets{% theme_head %}loads, plus a Tailwind preflight and a site reset. The cascade model orders by layer, then!important, then specificity, then source order, and matches element rules such asa:hoverandbutton. The cases cover every design system and preset in both modes. They require 3:1 contrast, check that the theme's own button colours still apply, and pin the box model and the anchor hover colour.
- The colour: that stylesheet's
- Component-event frames on an explicit view now carry the refreshed signed client snapshot (#3231). Since #3211 an event routed to a component (
component_id) on an explicit view commits the view'spersist="server"fields before it answers, but its frame did not carry the refreshedstate_snapshot_signedtoken (thepersist="client"fields) that the view route's frames carry. The client kept the token from an earlier turn, so a reconnect restored the server fields from after the component event next to client fields from before it: a mix of two turns that never existed as a state. After a successful commit, the full-HTMLhtml_update, the scopedpatchand thenoopof the component route now include the token, as the view route's frames do, and an explicit componentnoopsends its queued side effects (such as a navigation) after the frame, so the client stores the token before a redirect. Legacy views are unchanged. New cases inpython/djust/tests/test_exposure_component_snapshot_3231.py. - Closing an SSE stream now disposes a legacy view too (#3232). Since #3221 a normal SSE close calls
SSESession.shutdown(), but it disposed only explicit views. A legacy view'swait_for_eventwaiters stayed pending, its upload temp files stayed behind, its embedded children never ran_cleanup_on_unregister, and itsstart_async/@backgroundwork could still render into a queue nobody reads.shutdown()now gives a legacy view the WebSocket disconnect's legacy teardown: upload cleanup, waiter cancellation, child unregistration and Rust live-handle release. It also detaches the view from the session and runtime. As on the WebSocket, a legacy view's background work is not cancelled: it runs to completion. Its late result is then discarded, so no completion handler runs and no render happens, and the session drops any frame pushed after its close sentinel. As on the WebSocket, the legacy root's own_cleanup_on_unregisterdoes not run. Everyshutdown()caller gets this, including a rate-limit close. When an EventSource reconnects, its new session and view are untouched as the old stream closes. The explicit path is unchanged: it still cancels the view's background work, viadispose_child_subtree. New cases inpython/djust/tests/test_sse_legacy_close_3232.py. - A legacy view's
wait_for_eventwaiters are now cancelled when navigation replaces the view, and a replaced or disconnected view refuses new ones (#3236). Whenlive_redirect(WebSocket) or_replace_view(SSE) replaced a legacy view, its waiters were never cancelled. Nothing else held a waiter's future, so the view, the future and the background task blocked on it became an unreachable cycle. The garbage collector then destroyed the task while it was still pending (asyncio logged "Task was destroyed but it is pending!") and closed its coroutine withGeneratorExit, so the task'sexcept CancelledErrorcleanup never ran. Both replacement paths now cancel the waiters through one shared teardown (since #3244,release_root_view), so the task getsCancelledErrorand runs its own cleanup. The view's other background work still runs to completion, as on disconnect. Navigation, the WebSocket disconnect and the SSE close also mark the view closed (WaiterMixin._close_waiters), so await_for_eventthat background work starts afterwards raisesCancelledErrorat once instead of registering a waiter nothing would ever cancel. The SSE legacy replacement also no longer calls_cleanup_uploadson a view withoutUploadMixin: the call raisedAttributeErrorfor every such view and logged "SSE old view cleanup failed during navigation" on each navigation. New cases inpython/djust/tests/test_legacy_navigation_waiters_3236.py. - Back now restores a legacy
enable_state_snapshotview's state after a component event (#3237). Back reads two sources, and the session save wins:dispatch_mountuses the signed token only when there is no save. The component route missed both. It saved to the session only for aComponentDeclaration, so after any earlier view event Back restored that older save, and its frames carried no refreshed token, so without a save Back restored the mount-time state. A legacy opt-in view now saves on every component event, as the view route does on every event. The component route's full-HTMLhtml_updateand scopedpatchframes also carry the refreshedstate_snapshot_signedtoken, as the view route's frames do (#3098). A componentnoopchanged nothing and carries none, as on the view route. New cases inpython/djust/tests/test_legacy_component_snapshot_3237.py. - Closing an SSE stream now releases an explicit view's Rust live handles (#3239). The explicit branch of
SSESession.shutdown()disposed the view's subtree, which has no live-handle step, so the Rust view state kept strong references the garbage collector cannot see. It now calls_clear_live_handles, as the WebSocket disconnect does for every view and the SSE legacy branch does since #3232. New case inpython/djust/tests/test_exposure_sse_close_3221.py. - A legacy view's embedded children are torn down on every path that discards the view, and a legacy child's
wait_for_eventwaiters are cancelled (#3244). A WebSocketlive_redirectfrom a legacy page left its non-sticky{% live_render %}children registered and running (SSE navigation unregistered them), and an explicit child of a legacy parent was never disposed there. A legacy child's own waiters were cancelled on no path (disconnect, SSE navigation, SSE close orlive_redirect), so its waiting task was destroyed pending by the garbage collector ("Task was destroyed but it is pending!") and itsexcept CancelledErrorcleanup never ran. Every transport now tears a discarded root view down through one shared helper,_child_lifecycle.release_root_view: WebSocketlive_redirect, the WebSocket disconnect, SSE navigation and SSE close. Unregistering a legacy child (_unregister_child) now closes its waiters and unregisters its own embedded children before its_cleanup_on_unregisterhook runs. A sticky child thatlive_redirectkeeps is removed from the old page first, so it survives with its waiters and background work; one that is dropped (no slot on the new page, auth re-check denied, unresolvable or failed redirect, disconnect mid-redirect) goes through one helper,discard_sticky_child, which also closes its waiters. A{% live_render %}refusal of a reused sticky child goes through the same helper. An explicit root whose authority is revoked mid-connection (a server-originated turn or a released event is denied, closing with 4403) is now released too: the view was dropped before the close, so the disconnect never disposed it and its background work, waiters and live handles survived. A replaced view's Rust live handles are now dropped on navigation too, not only on disconnect (#3242 review). Thelive_redirectteardown also leaves the old view's db_notify groups, which it used to keep. New cases inpython/djust/tests/test_view_replacement_teardown_3244_3245.py. - A second
mountormount_batchframe on a mounted WebSocket now tears the replaced view down first (#3245). The stock client sends these frames on a live socket: lazy hydration mounts eachdj-lazyview with its ownmountframe (and falls back to per-view mounts whenmount_batchis refused), or onemount_batch. The replaced view was dropped with no teardown: its channel-layer groups kept the socket, so a push aimed at it was handled by the new view and the membership outlived the disconnect; its waiters were left for the garbage collector; its children were never unregistered. It now gets thelive_redirectteardown: it leaves its view, presence, presence-scope, db_notify and scoped-push groups, its tick task and deferred pushes are cancelled, and the view is released (release_root_view, #3244). Every view torn down this way, and every viewlive_redirector SSE navigation replaces, is also untracked from presence; before, only the view mounted at disconnect was untracked, so the others stayed in the presence list untilPRESENCE_TIMEOUT. Within onemount_batch, an earlier view is not torn down by the next entry: it stays mounted as a sibling, recorded with every channel-layer group it joined, and a later replacement or the disconnect tears it down and leaves exactly those groups. Before, the next entry reset a sibling's db_notify groups without leaving them (they outlived the disconnect), discarded its scoped-push group, and kept its view group past the disconnect. A sibling is not independently live: events and pushes reach only the last mounted view, and a push to a sibling's group is handled by that view. Multi-view routing on one socket is #3252. New cases inpython/djust/tests/test_view_replacement_teardown_3244_3245.py. - A legacy
enable_state_snapshotview's skip-rendernoopnow refreshes the signed back-navigation token when the handler changed state (#3246). A handler that changed state and set_skip_renderwas answered with anoopthat carried nostate_snapshot_signed, on both the view-event and the component-event routes. The client kept the token from before the change, so Back or a reconnect restored the older state whenever the token was the source (the page's session copy gone). Thenoopnow carries the refreshed token when the turn changed the view's state and the session save landed within its deadline. Anoopthat changed nothing, or whose save failed or was deferred, still carries none: the held token is then current, or it is the copy that matches storage. Behaviour change: a legacynoopthat now carries a token sends its queued side effects (push_event, navigation) after the acknowledgement, as explicit views already do, so the client stores the token before a queued redirect; a client hook sees the ack before the push, where before it saw the push first._persist_state_after_eventnow reports whether its save landed._skip_renderjoins_FRAMEWORK_INTERNAL_ATTRS: the flag is consumed toFalse, so a first_skip_renderleft a key the pre-handler snapshot lacked and read as a state change. Explicit views already attached_explicit_event_snapshotto every committednoopon both routes; new cases pin that, and that a failed commit sends no token. New cases inpython/djust/tests/test_skip_render_noop_snapshot_3246.py. - A state save still running after its request ended no longer writes into a session another request logged out (#3247). Since #3212 an SSE save runs on the dedicated save pool and can outlive the event POST, writing through the session object its turn captured, which a logout elsewhere does not change. Before writing, a pool save (legacy root, sticky child, explicit root and explicit child tree) now looks its session key up once and is dropped, with a debug line and no write, when the key no longer exists or the stored session names a different authenticated user. The lookup reads the store directly, so a storage error (a Redis or memcached blip, which Django's
cachebackend otherwise reports as a missing session) is not mistaken for a logout: the save goes ahead as before and the error is logged as a warning, with its traceback where diagnostics allow. An expiredfilesession counts as gone, and the lookup never writes (Django'sfileload()would create a new session file for it). A still-waiting explicit turn answers the reloadstate_errorwithout a storage-failure traceback. A key rotation through the save's own session object (login()in a legacy handler callscycle_key()) still saves: the store holds the pre-login copy under the new key, and this save is what persists the login. The newdjust._late_savemodule, ADR-038 D4 and the explicit-exposure guide document the window that remains: a logout between the lookup and the write still races it, and withcachesessions (check-then-set, no lock) can be overwritten. New cases inpython/djust/tests/test_late_save_logout_3247.py, plustest_a_late_pool_save_of_the_child_tree_after_a_logout_is_droppedinpython/djust/tests/test_exposure_child_pruning.py. - A legacy view's private-state session save no longer carries framework attributes, and a reconnect no longer restores them (#3248).
_snapshot_user_private_attrs,_get_private_stateand_restore_private_statechecked only the init-time_framework_attrs, not the_FRAMEWORK_INTERNAL_ATTRSlist every other capture path honours. So a framework attribute first set duringmount()was saved toliveview_<path>__privateand restored on the next mount. This was reachable, not only a future risk:start_async()inmount()saved_async_tasksand_async_task_counter. On both transports it was worse: the transports' per-connection identity (_websocket_session_id,_websocket_path,_websocket_query_string,_websocket_host,_websocket_secure,_django_session_key,_djust_mount_view_path, and SSE's_sse_session_id) was missing from the list, so the event save carried it, and a reconnect's session restore replaced the new connection's values with the previous connection's. All three methods now filter against the list, and those eight names are in it. New cases inpython/djust/tests/test_private_state_framework_attrs_3248.py.
Security
- Hardening: after a login rotated the session key, snapshot tokens and the VDOM cache key stayed bound to the pre-login key (#3248). A legacy
enable_state_snapshotview's private-state session save carried the transport's per-connection identity (_django_session_key,_websocket_session_id,_sse_session_idand the other connection names), and a reconnect's session restore put the previous connection's values back on the new view. Aftercycle_key()(a login) the restored_django_session_keywas the dead pre-login key, so signed back-navigation tokens issued afterwards were bound to it (and rejected on restore, which fails closed), the RustLiveView cache key stayed under it, and the stale values re-saved themselves on every event. No data was shown to cross between users; the effect is that part of the fixation defencecycle_key()provides was undone for these two bindings. Present since the per-event session save (at least 1.2.2 and 1.3.0rc5). The fix also repairs sessions that already hold stale values: the restore skips those names, and the save drops them even when an older session had them tracked.