djust 1.3.0rc6

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

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_snapshot view'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_event waiters, 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 .btn is 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=False to 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-action contract, with aria-labels and disabled state from editor.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 (from ![alt](src) in Markdown, or an <img> in a pasted, dropped or loaded HTML cell) is an inline node, so typing or a getEditor() 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 threw Called 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 or null (declared in djust.d.ts), and the MarkdownEditor hook's getEditor() 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)) and js/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 the markdown_controls.html include. Selecting text in Visual mode opens a formatting menu (bold, italic, code, link, plus the table actions inside a table); floating_menu=True adds 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.js now bundles @tiptap/extension-bubble-menu and @tiptap/extension-floating-menu at 3.31.3, the same exact pin as the other @tiptap packages, plus their MIT dependency @floating-ui/dom (with core and utils); the manifest, license file and djust.cdx.json are 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.js and js/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=False to MarkdownEditor(...) or the {% markdown_editor %} tag, bubble_menu=False to the markdown_controls.html include, or put data-bubble-menu="false" on a hand-written host. An explicit data-actions list 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 neither dj-root nor dj-view renders as a static page: djust stamps no dj-view and 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 has dj-view and the page has no dj-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-upload and others got no warning. Its trigger set is now derived from the client's directive table (djust._template_bindings.DIRECTIVES) plus dj-model, dj-upload and dj-upload-drop, and it matches modifier suffixes such as dj-keydown.enter. dj-hook, dj-update and dj-stream-mode work without a connection and do not trigger it.
    • A root that wasn't there: the dj-view test was a bare substring, so dj-viewport-bottom itself counted as a dj-view and 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 real dj-component or data-component-id attribute.
    • 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.
  • stream_done() and stream_error() no longer overtake a queued stream_to() update (#3227). stream_to() and stream_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 receive start, 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 before done (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 the clear() that followed (or to a "dictionary changed size during iteration" error), and a flush that finds ops queued behind it schedules a successor. The asyncio.sleep(2 * MIN_STREAM_INTERVAL_S) workaround is removed from the streaming AI tutorial. New cases in python/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::shutdown and SessionActorHandle::shutdown (and so the Python SessionActorHandle.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 failed use_actors=True mount 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 a Py<...>: 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 new SetPythonView / 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.py drops the pyo3-entry workaround #3226 added: a failed [discovery] mount's view is freed by a single gc.collect(). New cases in the release_3228 modules of crates/djust_live/src/actors/view.rs, crates/djust_live/src/actors/component.rs and crates/djust_live/src/actors/session.rs.
  • A plain .btn (no variant class) is readable when djust_components/components.css is loaded, hovered and at rest, in both modes (#3230).
    • The colour: that stylesheet's .btn set color: hsl(var(--foreground)) outside any cascade layer. It therefore beat the theme's @layer components .btn:hover colour 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 light buttonface under 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 .btn colour, and a zero-specificity :where(button.btn, input.btn) face of hsl(var(--muted)). A design system's own .btn background still wins, and an <a class="btn"> keeps its transparent background. A guard, html a.btn:where(:hover), sits above the theme's layered a:hover and 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.py resolve 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 as a:hover and button. 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.
  • 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's persist="server" fields before it answers, but its frame did not carry the refreshed state_snapshot_signed token (the persist="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-HTML html_update, the scoped patch and the noop of the component route now include the token, as the view route's frames do, and an explicit component noop sends 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 in python/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's wait_for_event waiters stayed pending, its upload temp files stayed behind, its embedded children never ran _cleanup_on_unregister, and its start_async / @background work 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_unregister does not run. Every shutdown() 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, via dispose_child_subtree. New cases in python/djust/tests/test_sse_legacy_close_3232.py.
  • A legacy view's wait_for_event waiters are now cancelled when navigation replaces the view, and a replaced or disconnected view refuses new ones (#3236). When live_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 with GeneratorExit, so the task's except CancelledError cleanup never ran. Both replacement paths now cancel the waiters through one shared teardown (since #3244, release_root_view), so the task gets CancelledError and 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 a wait_for_event that background work starts afterwards raises CancelledError at once instead of registering a waiter nothing would ever cancel. The SSE legacy replacement also no longer calls _cleanup_uploads on a view without UploadMixin: the call raised AttributeError for every such view and logged "SSE old view cleanup failed during navigation" on each navigation. New cases in python/djust/tests/test_legacy_navigation_waiters_3236.py.
  • Back now restores a legacy enable_state_snapshot view's state after a component event (#3237). Back reads two sources, and the session save wins: dispatch_mount uses the signed token only when there is no save. The component route missed both. It saved to the session only for a ComponentDeclaration, 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-HTML html_update and scoped patch frames also carry the refreshed state_snapshot_signed token, as the view route's frames do (#3098). A component noop changed nothing and carries none, as on the view route. New cases in python/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 in python/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_event waiters are cancelled (#3244). A WebSocket live_redirect from 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 or live_redirect), so its waiting task was destroyed pending by the garbage collector ("Task was destroyed but it is pending!") and its except CancelledError cleanup never ran. Every transport now tears a discarded root view down through one shared helper, _child_lifecycle.release_root_view: WebSocket live_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_unregister hook runs. A sticky child that live_redirect keeps 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). The live_redirect teardown also leaves the old view's db_notify groups, which it used to keep. New cases in python/djust/tests/test_view_replacement_teardown_3244_3245.py.
  • A second mount or mount_batch frame 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 each dj-lazy view with its own mount frame (and falls back to per-view mounts when mount_batch is refused), or one mount_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 the live_redirect teardown: 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 view live_redirect or 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 until PRESENCE_TIMEOUT. Within one mount_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 in python/djust/tests/test_view_replacement_teardown_3244_3245.py.
  • A legacy enable_state_snapshot view's skip-render noop now refreshes the signed back-navigation token when the handler changed state (#3246). A handler that changed state and set _skip_render was answered with a noop that carried no state_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). The noop now carries the refreshed token when the turn changed the view's state and the session save landed within its deadline. A noop that 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 legacy noop that 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_event now reports whether its save landed. _skip_render joins _FRAMEWORK_INTERNAL_ATTRS: the flag is consumed to False, so a first _skip_render left a key the pre-handler snapshot lacked and read as a state change. Explicit views already attached _explicit_event_snapshot to every committed noop on both routes; new cases pin that, and that a failed commit sends no token. New cases in python/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 cache backend 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 expired file session counts as gone, and the lookup never writes (Django's file load() would create a new session file for it). A still-waiting explicit turn answers the reload state_error without a storage-failure traceback. A key rotation through the save's own session object (login() in a legacy handler calls cycle_key()) still saves: the store holds the pre-login copy under the new key, and this save is what persists the login. The new djust._late_save module, 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 with cache sessions (check-then-set, no lock) can be overwritten. New cases in python/djust/tests/test_late_save_logout_3247.py, plus test_a_late_pool_save_of_the_child_tree_after_a_logout_is_dropped in python/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_state and _restore_private_state checked only the init-time _framework_attrs, not the _FRAMEWORK_INTERNAL_ATTRS list every other capture path honours. So a framework attribute first set during mount() was saved to liveview_<path>__private and restored on the next mount. This was reachable, not only a future risk: start_async() in mount() saved _async_tasks and _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 in python/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_snapshot view's private-state session save carried the transport's per-connection identity (_django_session_key, _websocket_session_id, _sse_session_id and the other connection names), and a reconnect's session restore put the previous connection's values back on the new view. After cycle_key() (a login) the restored _django_session_key was 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 defence cycle_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.

All releases · Atom feed