djust 1.3.0rc15

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

Before you upgrade, read Deprecated below and the upgrade guide.

Added

  • LIVEVIEW_CONFIG["http_fallback_background_timeout"] bounds the work the HTTP page-POST fallback runs inside the request (#3634). Since #3630 the fallback runs start_async / @background work before it answers, and nothing bounded a task: a mount() that starts a watcher held every POST open for as long as it ran. Set the key to a number of seconds (default None, no deadline, and no change in behaviour) and one POST waits for background work for that long in total, across mount's work, the handler's and a page's after a child event. Each task then runs on a worker thread (a sync callback runs on the request thread otherwise, so no timeout could free the request). At the deadline the fallback answers from the state it has: the running task is abandoned, every task still queued is dropped without starting, a warning names them, and handle_async_result receives a TimeoutError as error= for each. An async def callback is cancelled; a sync callback cannot be and keeps running, its result discarded (it can still write to the view the answer was rendered from). At most 16 background worker threads (abandoned ones still alive plus ones a POST is waiting for; slots are reserved atomically, so concurrent POSTs cannot overshoot) may exist per process; past that a task is not started (warning, TimeoutError for handle_async_result). An async def callback runs on a private event loop under the key. A value that is not a number from above 0 up to threading.TIMEOUT_MAX means no deadline, and system check djust.C028 reports it. The guide's hard-limit paragraph is replaced with this contract (docs/website/guides/http-only-mode.md).

  • djust_audit --a11y and manage.py check gain Y005 (a chart or <svg> with no accessible name) and Y006 (a legend told apart by colour alone) (#3685). A hand-built chart passed all four existing Y rules even when a screen reader got nothing from it. Y005 flags an element with role="img", or an inline <svg>, that has no non-blank aria-label / aria-labelledby / title and, for an <svg>, no <title> child (WCAG 1.1.1); it skips aria-hidden="true", role="presentation"/"none", an <svg> inside a <button> or <a href> (the control owns the name; Y001 covers a control with none), a nested <svg>, hidden sprite sheets, opening tags with {{ }}/{% %} between their attributes, HTML comments, and <script>/<style> bodies. Unlike Y001, an empty aria-label="" is not a name. Y006 flags a <li> inside an element whose class has a legend token (legend, chart-legend, legend__list) that has no text, no <img> with an alt, no {{ }}/{% %} and no name attribute (WCAG 1.4.1). Both are warnings, suppressible by id (Y005, Y006), and are found by a one-pass tag scan that stays linear on unterminated and unclosed tags. Not included: the rendered-page axe-core pass from the issue (contrast, heading order, names that exist only after render), which needs a browser and is tracked separately. Documented in the accessibility guide, docs/system-checks.md and the error-code reference.

    Behavior change: (a) Y005 and Y006 are registered system checks, so manage.py check (and check --fail-level WARNING) now report them for every project, not only djust_audit --a11y. (b) A decorative icon <svg> outside a <button>/<a href> with no aria-hidden is reported by design; on the demo project all 11 hits were such icons. (c) Mark decorative graphics aria-hidden="true", name a chart with aria-label/<title>, or silence with DJUST_CONFIG['suppress_checks'] = ['Y005'] (or Y006).

  • A view can declare its own query budget: LiveView.query_budget, read by LiveViewSmokeTest (#3686). LiveViewSmokeTest had one max_queries for every view of an app, so a view that renders in 3 queries could grow tenfold and still pass under the limit a heavy report needs. query_budget = 3 on a view caps its mount plus first render together; query_budget = {"mount": 30, "render": 5} caps each phase on its own (a phase left out falls back to max_queries). test_smoke_queries now takes each view's limit from view_config[View]["max_queries"] (new), else View.query_budget, else the class max_queries, and reports a view over budget by name with where its limit came from (shop.views.ProductListView: 5 queries (max 3, from query_budget)). A budget the smoke test cannot enforce (a dict key other than mount/render, a bool, a negative or non-int value) fails the test with a ValueError naming the view rather than being ignored. The default message for a view with no budget is unchanged. Run-time measurement, event budgets and the djust_audit listing follow in #3701. Documented in the testing guide, the smoke/fuzz guide and the LiveView and testing API references.

  • http_status_of_state decides a page's HTTP status once, from the state it already has (#3688). LiveView.get_http_status() (#3558) answered 200 unless a view overrode it, so a page that draws its own not-found state answered 200 unless that view, and every other view like it, remembered the override. Set http_status_of_state = {"not_found": 404, "forbidden": 403, "gone": 410} once, on a base view, and the default get_http_status() answers the status mapped to the view's state (state_attribute names another attribute). Only a string state is looked up; the map defaults to empty, so a view that declares nothing, and a view that overrides get_http_status(), answer exactly as before. Only the HTTP response has a status (a socket frame has none), and the streaming render answers it too. 13 regression cases in python/djust/tests/test_http_status_of_state_3688.py. Behavior note: http_status_of_state, state_attribute and params_form are now framework class attributes, so a view with exposure_policy = "explicit" cannot declare a state() field under those names.

  • params_form declares, validates and types a view's URL parameters (#3689). handle_params(params, uri) received the query string as raw strings, so the example in its own docstring (int(params.get("page", 1))) answered 500 on ?page=abc, and every view that reads its URL wrote its own parsing, defaults and idea of what a bad value does. Set params_form to a Django form and djust binds the URL's parameters to it before handle_params(), which receives cleaned_data (the form's initial values fill every field the URL left out or empty, a BooleanField and a multi-key widget such as SplitDateTimeField included). Invalid input calls the new handle_invalid_params(form, uri) instead; its default hands handle_params() the initial values, and the messages reach the template as params_errors ({field: [messages]}, errors that belong to no field under "__all__"), so ?page=abc renders the default page with the error. Override it to raise Http404, or to answer 400 through get_http_status(). One helper (python/djust/_params_form.py) runs for the HTTP GET, the stateless HTTP fallback POST, the WebSocket and SSE mount, and url_change (live_patch and back/forward); on the socket paths validation runs on a worker thread inside a guarded handle_params sync-turn step, so a ModelChoiceField can query. A view without params_form keeps receiving the raw dict. The guide and the handle_params docstring now use the form (docs/website/guides/navigation.md). 53 regression cases in python/djust/tests/test_params_form_3689.py.

  • A view's query_budget is measured at run time, accepts event budgets, and is listed by djust_audit (#3701). Follows #3686, where query_budget was read by LiveViewSmokeTest only. Run time: a view that declares a query_budget has the queries of each phase counted, on the thread that runs the step (a sync handler or mount() runs on a worker whose database connection a counter on the event loop would never see), and a phase over its number is a warning on the djust._query_budget logger while DEBUG is on or under pytest; with LIVEVIEW_CONFIG["query_budget"] = "error" the step raises QueryBudgetExceeded instead ("warn" measures whatever DEBUG says, "off" never). The meter hooks djust.sync_turn.run_sync_step, which every sync hop of every transport already goes through, so the HTTP GET, the WebSocket and SSE mount, url_change, events and the test client share one definition. A view with no query_budget pays one class attribute read per sync hop and installs nothing on the connection. Event budgets: query_budget takes "event": n (every @event_handler) and "events": {handler: n} (one handler, which wins), and the new LiveViewSmokeTest.test_smoke_event_queries calls each budgeted handler with the fuzz payloads plus view_config[View]["event_params"] and fails when a call ran more queries than its budget, whether or not fuzz is on. A handler named in "events" that no call succeeded for is an error rather than a pass, and one covered only by "event" is a UserWarning; a handler name the view does not have, or an async def / @background handler (nothing can measure those), is a ValueError, as is any other unknown key, so a budget nothing enforces is still rejected. One reader, djust._query_budget.declared_budget, serves the smoke test, the run-time meter, V024 (a new Error check for an invalid query_budget) and djust_audit, and a test pins that nothing else reads the attribute; an event is the handler's own queries in the smoke test and at run time alike. An invalid LIVEVIEW_CONFIG["query_budget"] is C029 at startup, and a request reads it as "warn" and logs that once instead of failing. Audit: djust_audit lists each view's budget, (none), or invalid: <reason> (query_budget_error in --json, which never crashes over a bad declaration). Not measured: async def and @background handlers, start_async work and handle_async_result, ticks, the authorization hops, the on_mount hooks and connected() / disconnected(), a step nested inside another (an embedded live_render child's get_context_data()), and any database but the default. On an HTTP GET a view that overrides get_context_data() has it called twice (measured; framework behaviour, not changed here), and each pass is judged on its own, so a render or int budget under-reports the page. Documented in the testing guide, the smoke/fuzz guide, the djust_audit guide and the LiveView and testing API references.

  • V023 checks params_form; LiveViewTestClient drives handle_params; params_form, http_status_of_state and state_attribute are reserved names (#3706). A params_form that is not a form class raised ImproperlyConfigured, and a form whose __init__ needs an argument beyond data= (a user, a queryset, a tenant) raised TypeError, both on the first request. manage.py check now reports each as V023 (Error), using the request path's own verdicts (djust._params_form.form_class_problem and construction_problem) for every failure visible in the signature; it reads signatures and instantiates nothing, so an __init__ that raises, a positional-only data the form's super().__init__ rejects, or a lazy object wrapping a form class still fails on the first request. LiveViewTestClient.mount() followed mount() with nothing, so a view declaring params_form could not be unit-tested through it: it now calls handle_params(params, uri) right after mount(), as the HTTP GET and the socket mount do, through the same params_form decision, and takes mount(query_params={...}) (a mapping or the query string) for the URL's parameters and a new url_change(query_params) for a live_patch / back-forward frame. The three names are also in the framework-internal attribute set: a legacy view that assigned self.state_attribute = ... (or params_form, http_status_of_state) in mount() published it as reactive state; an exposure_policy = "explicit" view could already not declare a state() field under them, and a test now pins that. Not in this change: a LIVEVIEW_CONFIG["http_status_of_state"] project-wide default (it needs a precedence rule between the view attribute and the config), a V013-style check that depends on it, the Http404-from-mount() alternative, and a per-field handle_invalid_params fallback; #3706 stays open for them. Documented in the navigation guide, the testing API reference, docs/system-checks.md and the error-code reference.

  • scripts/check_pr_body.py and a non-gating pr-body-lint workflow: lint a PR body for placeholders and dangling references (#3713). It fails on unresolved placeholder tokens (TBD, XXX, <N>, N tests, #NNN), also inside a URL (.../pull/NNN, .../runs/XXXXXXXX, #issuecomment-TBD), and on a #<number>, a github.com/<this repo>/(issues|pull)/<number> URL, an <this repo>#<number> or an issuecomment-<id> that does not resolve in this repo. It leaves fenced code (including one opened on a list-item line), inline code, <code> / <pre>, HTML comments, # inside URLs, the text of a link (the target is checked, not the label, for inline and reference-style links), owner/repo#N of another repo, a word or slash glued to the #, List<N> and HTML entities alone; 4-space indented code is not recognised; a line carrying pr-body-lint: ignore is skipped. The logic is a pure function with the resolver injected; a lookup that could not be made (network, rate limit) is unchecked and does not fail the run. Its work is bounded: at most 50 distinct references are looked up per body and the rest are reported as one too many references to check note, and every pattern is linear (a 64 KiB body of [ or <a> is instant). --annotate prints ::warning lines so findings show on the run summary of a job that is green. The workflow (.github/workflows/pr-body-lint.yml) is continue-on-error: true and does not appear in test.yml's test-summary condition, so it cannot block a merge until it has run green on the runner once (the rc4 rule); a test parses the YAML and pins the trigger (pull_request only), a read-only token, no ${{ }} in any run:, and the body arriving only through an environment variable. Run over the last 200 merged PR bodies it reports no findings; the first pass over the last 40 found one false positive (a Dependabot body linking another repo's issues as <a href=...>#363</a>), which is why link text is not read as a reference. 61 regression cases in tests/unit/test_check_pr_body_3713.py; scripts/check-pr-body-gateoff.json (run with scripts/gate_off.py) drops 48 rules in turn and every drop turns a test red. It does not cover claims about code, which stay with the differential-test habit. docs/patterns/unverified-claim.md also records that gh run view --log-failed and --log show a truncated view of a long job and to read the job log with gh api --allow-escape-sequences repos/<repo>/actions/jobs/<id>/logs.

  • scripts/gate_off.py: one shared gate-off harness (#3714). State the mutations as data (file, text to find, replacement, label, and the test command that must catch it) in a .json or .toml file and run python scripts/gate_off.py <file> to get a CAUGHT / SURVIVED / INVALID table. It implements the rules CLAUDE.md already states (the issue records five v1.3.0-15 PRs each re-learning them in a throwaway harness). A mutation text that is not found (or found the wrong number of times), a replacement that changes nothing, a .py target that no longer compiles, a smoke command that fails on the mutant, a command that cannot start, tests that fail to collect (pytest's 1 error, not 1 failed), a hang or runaway during collection (an import-time hang is the same "broke the module" as an import-time raise) and a run in which no test passed are all INVALID, never a number; runner: "exit" reads only the exit status and treats 126/127 as INVALID. Test-time errors are counted with failures. Each run has a deadline and an RSS ceiling over its whole process group and a run-time runaway is reported as CAUGHT (HUNG / RUNAWAY-MEMORY) with the measurement. The file is restored from saved bytes in a finally (also on SIGINT, SIGTERM, SIGHUP and atexit), written to a new inode with a fresh mtime, and verified byte-identical by SHA-256; any restore failure, including an OSError, is a RestoreError (exit 3). __pycache__ for each touched file is cleared before and after, and a symlinked target is mutated through the link and left a link. Overlapping runs are refused and a killed run is recovered, by two mechanisms: before anything runs the harness takes an exclusive flock on the directory of every target (the directory's inode survives the mutation's os.replace; the lock needs no environment variable or state directory, so a second run under another HOME is still refused with exit 4 and touches nothing; two files of one directory exclude each other, files in different directories do not), and before the first write it journals the original bytes and the mutant's hash under the target's full resolved path in a directory outside the tree ($GATE_OFF_STATE_DIR, else .cache/gate-off in the home the user database gives, not $HOME); a journal left by a SIGKILLed run is restored from before anything is snapshotted (or refused, exit 4, if the file was edited since). Recovery needs the same journal directory: a run killed under one explicit $GATE_OFF_STATE_DIR and followed by a run under another can still adopt the mutant, so git status after a SIGKILL remains the backstop. The harness tolerates only benign conditions (a process group already gone, a temp file already removed, the poll tick); a group that cannot be killed, a .pyc that cannot be deleted or a ps that is unavailable stop the run instead of turning a bound off. A red unmutated baseline stops the run. It cannot judge an equivalent mutation or two shadowing mechanisms; a SURVIVED row prints that question. 66 regression cases in tests/unit/test_gate_off_3714.py run it against a real fixture package (caught, survived, no-op, import-raising, import-hanging, setup-error, endless-loop, runaway-allocation, smoke-failing mutations; a crash, a real SIGINT / SIGTERM, an overlapping second run and a SIGKILL mid-run all end with the file restored or the second run refused). The harness is gate-off'd with itself: scripts/gate-off-selfcheck.json drops each rule in turn and every drop turns a test red. CLAUDE.md's three gate-off paragraphs now point at the script.

  • Each pytest process now reports its worst generation-2 GC pause and its longest stall in the terminal summary (#3715). tests/timing_report.py (registered in the root conftest.py) records gen-2 pauses from gc.callbacks and stalls from a 20 Hz daemon ticker thread that notes how late it ran (a thread needs the GIL, so a stop-the-world collection, a native call that holds it, a VM pause or CPU starvation each show up as one late tick), each with the test that was running. Serial runs print one line; under xdist every worker ships its numbers to the controller through config.workeroutput and the controller prints one line per worker, so the next timing failure on CI can be attributed from the log. It changes no outcome and no exit code, prints nothing for --collect-only, and removes its callback and thread when the session ends. Pinned in tests/test_timing_report_3715.py, which runs it through pytester, serially and under -n 2.

  • DJUST_CONFIG["debug_panel"] turns the DEBUG-only debug panel off. Default True: the panel loads whenever DEBUG is on, as before. With False, djust keeps client-dev.js (hot reload, toasts, error overlay) but serves no debug-panel.js / debug-panel.css, no window.DJUST_DEBUG_INFO, no toolbar button or icon, no per-event _debug payload on WebSocket or HTTP responses, and generate_sw leaves debug-panel.js out of the precache list. One reader, djust.config.debug_panel_enabled(), gates every entry point. Useful when a development Content-Security-Policy blocks the panel's icon and inline style= attributes and fills the console with violations (docs/website/advanced/debug-panel.md).

  • System check djust.T027: a JSX-style <RustButton … /> template tag. The tags are deprecated since 1.3 and removed in 2.0.0, and rendering one emits a DeprecationWarning. The check finds them in templates before the page is requested, as the deprecation policy asks of template-syntax deprecations. New cases in TestT027RustJsxTags.

  • static_render = True: a view that renders once keeps no diff baseline (#3626). For a page that is rendered on the GET and the socket's mount and then not patched (a report, a diagram, a document), djust kept the rendered tree and a copy of every context value in the live view and in every stored snapshot, for a diff that never came. A view that sets the attribute drops both after each render (new RustLiveView.release_baseline(), which, unlike reset(), keeps the version counter, plus retain_state_keys([])), so the state backend stores only the template and the version. Measured on a page rendering a cached ~200 KB diagram of 2,000 nodes: 474,054 bytes stored after the GET (context 204,004, tree 269,862) became 196, and the Large LiveView state warning, which measures the stored bytes, no longer fires. Every later render is full and correct instead of a patch: an event that changes state answers html_update over the socket (html on the HTTP fallback, with consecutive versions, with view_state_in_session on or off), an event that changes nothing still answers noop, work started at mount (start_async) answers the whole page once it finishes, and navigating away and back, a reconnect and a mount after the GET render the page from the view's state. Such a full frame reports the reason static_render and logs no DJE-053 warning. An embedded or sticky child keeps its own baseline (a child with handlers inside a static page, and a static child inside a normal page, both update as before), and a native (LVN) renderer is never released. Opt-in, default off: a normal view is unchanged. The warning's advice names the attribute, and the Best Practices guide's "Large LiveView state" section documents the trade (a full render and a full-page frame per later render, against memory per open page and per stored snapshot) and when not to use it (pages whose own handlers update them often, temporary_assigns, streams). Stream rows in the snapshot of a view that does patch remain #3632. 29 regression cases in python/djust/tests/test_static_render_3626.py, among them a value of every shape the change detector compares differently (an immutable, a container, any other object), static_assigns on a static view, a bound component's event (it takes the whole page, where the same event on a normal page is the scoped patch), and the diagnostics (no DJE-053, the full_html_update signal's reason is static_render), the destination <title> after a dj-navigate (a title built from a value get_context_data computed still reaches the tab: the release keeps exactly the values the <title> reads), and temporary_assigns (the rows are gone after the first event, so the view logs one warning per class naming the combination). A Rust test, release_baseline_drops_the_tree_and_every_cache_built_from_it, pins that release_baseline drops the tree, the cached HTML, the node cache, the pending changed set and both text indexes.

  • A parity ratchet for djust's own template libraries (#3663, #3669). make template-library-parity renders every djust_components and theme_tags tag that has a native handler through Django's engine and through DjustTemplateBackend, and compares a per-tag table with the checked-in scripts/template-library-parity-baseline.csv: the class (IDENTICAL, DIFFERENT-escape-policy-only, DIFFERENT, UNEXERCISED), how many variants differ and in which aspects, which variants one engine rejects, and how much of the tag was exercised. Discovery follows each handler into the render_* helper that reads its arguments, every string parameter is called alone with a mark_safe and a hostile value, and a child tag is rendered inside an open parent and compared on what it added. The ratchet is exact in both directions: a tag that gets worse fails, and so does one that gets better (with the command that regenerates the baseline). The harness runs in its own process with fixed uuids and clock; python/tests/test_template_library_parity_3663.py carries its gate-offs and scripts/template-library-parity-gateoff.py mutates the ratchet and the tags to show each turns the test red. New pr_tier test marker (tests/slow_tier.py): a test carrying it stays in the PR fast tier however long .test_durations records it.

Changed

  • X105 also flags CSS named colours and style= attributes, and a new X106 flags literal font-family stacks (#3687). djust_audit --ast already flagged hex and colour-function literals in the stylesheets a LiveView template links; three neighbouring cases passed. X105 now also counts a CSS named colour (white, red, black, ...) in a property that can hold a colour (color, background, border*, outline*, box-shadow, fill, stroke, and similar) and as the whole value of a custom property, so animation-name: orange and grid-area: tan are not findings; transparent, currentColor, the CSS-wide keywords and any literal inside a var() fallback stay allowed. It reads the style="..." attributes of templates in the live closure the same way (a {{ }} value is unknown, not a literal; a {% %} tag is skipped and its branches are read) and reports one finding per template, with {# djust: noqa X105 #} on the declaration's line to suppress. New X106 flags a font-family that is not a var(--font-*) theme token, and a font shorthand that names a family, in linked stylesheets (outside @font-face) and in style attributes; inherit, initial, unset and revert are allowed. X106 has its own code (/* djust: noqa X106 */, {# djust: noqa X106 #}), is a warning, and is listed in the UI summary cache (rules, counts) without triggering djust.I003.

    Behavior change: (a) djust_audit --ast reports more X1xx warnings, and X106 is new. (b) Projects that run djust_audit --ast --strict in CI will start to fail on the named colours, style attributes and font stacks they already had. (c) Fix the finding with a theme token (hsl(var(--foreground)), var(--font-sans)), suppress one declaration with noqa X105 / noqa X106, or keep the previous scope with --ast-no-ui.

  • LiveViewTestClient.mount() now runs handle_params() (#3706). The client used to call mount() only, so state a view builds in handle_params() was missing under test while the HTTP GET and the socket mount always had it. A test of a view whose handle_params() assumes a key the URL does not carry, or raises, now fails the way the page does; the same applies to assert_http_ws_djid_parity() and create_test_view(), which mount through the same code. Two snippets in the navigation guide called helpers they never defined from handle_params() (fetch_products, _load_tab_data); the doc-snippet harness had never run those hooks, found them once the client did, and the snippets now define them.

  • Test waits on Channels communicators take the 30 s default again; a ratchet keeps explicit short timeouts from regrowing (#3709). An explicit timeout=3 on receive_json_from / receive_from / receive_output / connect / disconnect overrides the 30 s default the root conftest.py installs (#3692), so the wait is a 3 s wall-clock bound again; a stop-the-world GC pause was measured at 3.38 s (#3708). 433 short awaited waits in 156 test files lost their timeout=: 404 plain waits, and 29 inside a try (26 except: pass drains, mostly of the connect frame, which always arrives; 2 except: break loops that read until a close or error frame the test then asserts; 1 benchmark fallback except: response = {}, which keeps its try). 34 helper definitions whose short timeout= default was forwarded to a wait (_receive_until(..., timeout=3) and its siblings) default to 30 s. python/djust/tests/test_communicator_timeouts_ratchet_3709.py counts, by AST, an awaited communicator wait with a short numeric timeout, a helper with a short forwarded default, and a call to such a helper with a short literal, per file against a baseline; it exempts only absence idioms (receive_nothing, the last line of a pytest.raises block). It does not cover everything: waits whose timeout is a variable (remaining, deadline), asyncio.wait_for(..., <10) and asyncio.timeout(<10) (not communicator waits), and helpers imported from another module are counted in UNCOVERED_BASELINE (which also only falls) but not converted, so #3709 stays open for them. try/except: pass drains were exempt as absence idioms in this change; four of the six read until a frame that does arrive and were converted, and the ratchet counts drains from then on (#3726). BASELINE entries are on purpose, each with its reason in BASELINE_REASONS (pinned to the same files by a test); for example python/djust/tests/test_ws_communicator_defaults_3464.py pins that an explicit timeout still wins over the default, so it passes timeout=0.01.

  • No test bounds a duration with an absolute number of seconds any more, and a ratchet keeps it that way (#3711). The sweep behind #3710 found 31 asserts in 23 test files that compared a measured duration with a constant (the issue named 16 lines). A complexity claim ("linear on tag soup", "bounded") is now judged by growth with djust.tests._growth.assert_scales_linearly in python/djust/tests/_growth.py (four times the input must cost under ten times as much, interleaved, best of three, on this thread's CPU time), and a "did not block" claim by an ordering (a test-owned event, a frame received while another call is still blocked). python/djust/tests/test_no_absolute_time_bounds_3711.py finds the shape by AST and lets the per-file count only fall; its baseline is empty. It follows elapsed = _took(fn) and self._took(fn), budget_ms = ..., await helper(), datetime.now() / timezone.now() spans, loop.time() however the loop is named, aliased clock imports (from time import perf_counter as pc, import time as t, from time import time), assertLess / assertLessEqual / assertGreater / assertTrue, if elapsed > N: raise / pytest.fail, assert not elapsed > N, module-level asserts and the walrus, with a positive test per variant. Three of the sites were not testing what they claimed. test_save_block_timeout_logs_and_continues_end_to_end bounded the round trip at 2 s although the slow body it stood in for costs 0.25 s, so the bound could not see the wrapper's 150 ms timeout go; and two growth checks fail on real behaviour and are xfail(strict=True) with the measurement, not fixed here: _blank_template_comments is quadratic on {% comment %} openers (#3723, python/djust/tests/test_template_checks_skip_comments_3283.py) and extract_template_variables grows about 6-8x per doubling of the template's blocks, roughly n^2.9 (#3724, python/tests/test_template_variable_extraction.py).

  • Test files no longer end a wait for a frame that arrives with a 2-3 s window; the #3709 ratchet counts those waits per file (#3726). test_live_auth_outside_declarations.py, test_live_auth_event_holders_and_user_checks.py, test_model_form_route_change_3125.py and test_mount_object_permission_after_params.py read frames until a mount, a close or a navigation answer that DOES arrive in the positive case, with receive_output(timeout=2) / receive_json_from(timeout=3) inside a try: ... except: pass, and then asserted it had arrived. A runner that stalled past that window failed 68 tests (a 3.5 s delay on every outgoing frame reproduces it). They now wait with djust.tests._ws_frames.receive_until (a 10 s deadline of its own, not the 30 s default) and return the decoded frames. test_security_view_import_failclosed.py and test_sync_turn_wrapper_more_hops_3637.py wait for the error frame that refuses the mount before they assert on the absence of a mount or on the state (the 3637 one asserted "no mount" against [] under a stall); test_recovery_version_staleness_1817.py passes 30 instead of 2 / 3 to its _recv; and test_anonymous_event_after_refused_mount_does_not_run now sends its event whatever the mount answered (it never did). test_communicator_timeouts_ratchet_3709.py no longer exempts a try whose handlers only pass / break / continue, and counts per file a communicator wait whose timeout reads as a number under 10 s: a literal, arithmetic on literals, a module constant, **{"timeout": 2}, in a keyword or a positional slot, wrapped in asyncio.wait_for / asyncio.timeout or not, and a call to a forwarding helper with a short keyword or positional timeout. What it cannot read as a number (a parameter, a call) and bounds on things that are not frame waits stay in UNCOVERED_BASELINE as totals that cannot name the file. Genuine absence windows sit in BASELINE with a reason in BASELINE_REASONS; the last line of a pytest.raises block is still exempt. #3709 stays open for the variable-timeout and wait_for classes.

  • The dj-params deprecation warning now shows a typed replacement. Its example is dj-value-todo-id:int="{{ todo.id }}" rather than an untyped dj-value-* attribute, because dj-params carried JSON types and a plain dj-value-* value arrives as a string. client.js and client.min.js are regenerated.

  • Packaging: daphne is now an optional extra, djust[daphne] — djust's base dependency is plain channels>=4.0.0,<5 instead of channels[daphne], so a bare pip install djust no longer installs daphne or, through daphne -> twisted[tls], Twisted (or autobahn, pyOpenSSL and service-identity). Apps served by uvicorn or hypercorn, which never import either, stop carrying Twisted's advisories in their dependency audits; eight of them, most recently GHSA-vpp8-g58w-m8mp (HTTP/2 rapid reset), had to be ignored downstream. Migration: an app that serves with daphne, or lists "daphne" in INSTALLED_APPS for its runserver override, must now install djust[daphne] (or depend on daphne directly); without it, Django fails at startup with ModuleNotFoundError: No module named 'daphne'. A test suite that imports channels.testing (for example WebsocketCommunicator) needs it too, because Channels' own test package imports daphne.testing; djust's LiveViewTestClient does not. djust new projects already use uvicorn and plain channels and need no change. djust[all] and the dev extra include daphne. djust_doctor's no-ASGI-server hint now names pip install 'djust[daphne]', and the installation, system-check and error-code docs say how to get daphne. tests/test_base_dependencies_no_daphne.py fails if daphne or Twisted becomes a base dependency again, directly in pyproject.toml, transitively through uv.lock, or in the installed package metadata.

Fixed

  • ManualClock.sleep no longer loses a wake-up when advance runs before the sleeper's first step (#3451). RoomClock._wait turns its absolute deadline into seconds and passes ManualClock.sleep(seconds) to asyncio.create_task; the old async def sleep computed self.value + seconds when the task first ran, so an advance() that ran in between (a task already ready ahead of the sleeper in the loop's queue) left the sleeper registered seconds past the time it was asked to wake at, and nothing woke it. sleep() now fixes its deadline when it is called, and a sleeper that is already due at its first step returns at once. With that window widened by an injected delay, test_presence_timeout_retries_after_retirement_without_another_join fails after 30 s with no tick and the log signature of the CI failures (Clock presence-retry started, then stopped (graceful) with nothing between), and passes with the fix. The natural interleaving was not observed, so that this was the cause of the CI failures is the best-supported explanation, not a proven one; the failing run's djust timing report (#3715) showed no stall longer than 1.3 s on the failing worker, which rules out a 30 s runner stall for that sighting. Custom time_source objects should measure sleep from the call, as documented in the shared-room clock guide. New cases: test_manual_sleep_deadline_is_fixed_when_sleep_is_called, test_manual_sleep_already_due_at_its_first_step_needs_no_settle and test_clock_ticks_when_time_moves_between_its_wait_and_the_sleeper_start in test_shared_room_clock_3005.py.
  • A release run blocked by the pre-release security audit now raises an alert — release.yml gains an audit-blocked-alert job that opens one release-labelled issue per tag (or comments on it when the run is retried) naming the tag, the failing audit job(s) and the run URL, so a blocked tag no longer sits unpublished unnoticed (#3482).
  • The stateless HTTP fallback POST now runs handle_params() and start_async work (#3624). With view_state_in_session: False (and whenever the fallback rebuilds the view with mount()), the POST called mount() but never handle_params(params, uri), so a page that read its URL there met the event with its defaults and answered with a diff that removed the rows the user was acting on; it also discarded start_async / @background work, so a page that loads after its first render kept its loading state on every POST. The fallback now calls handle_params() after mount() with the POST's query string (one shared helper with the GET), and runs queued background work inside the request, before the answer is rendered, through the dispatch the socket and SSE share. The one-off "background work does not run" warning is gone. Refs #3503: its push_event / set_layout / defer() gaps were already closed by #3541; new tests pin that every queue a view can fill reaches the HTTP answer.
  • sync_turn_wrapper now covers handle_tick() and handle_async_result(), and the test client (#3625). An app that opens a per-turn database scope in the wrapper (row-level security, a request-scoped context variable) had it in every step but these two: the tick and the hook that receives a start_async result ran on the worker thread with no scope, so rows read there came back empty or wrong. handle_tick is now the "tick" step (stock and worker_threads shapes) and handle_async_result an "async" step on every owner: the consumer, the runtime's explicit and legacy result turns, and embedded and sticky children. LiveViewTestClient.send_event(), send_push(), trigger_info() and render_async() (callback and handle_async_result) and LiveComponentTestClient.send_event() enter the same steps as the consumer, so a test of a handler no longer runs without the scope production gives it; an async def push or info hook is now run to completion (on a helper thread inside a running event loop) rather than left as an unawaited coroutine, and an async def handle_async_result or default-path handle_tick is refused with the same TypeError with or without a wrapper. A structural test pins the set of places that reference the two hooks and that each enters the step through the shared helper. The sync hooks that are still not wrapped (object-permission checks, on_mount, connected/disconnected, cursor and presence hooks, time-travel replay) are listed in the turn middleware guide and tracked in #3637. New cases in python/djust/tests/test_sync_turn_wrapper_tick_async_3625.py.
  • ThemeMixin no longer puts about 80 KB of theme HTML in every view's state, and the Large LiveView state warning now says what it measured (#3626). ThemeMixin._setup_theme_context() assigned theme_head, theme_css, theme_switcher and theme_presets as public attributes, so each was serialised into the stored Rust snapshot and the session save, and the private _theme_css_raw (the push-event CSS, ~18 KB) into the private-state save, even on a page that only prints the {% theme_head %} tag. They are now supplied to the template lazily, beside the context processors' output (ThemeMixin._apply_context_processors), and view.theme_head and the other three still work in Python as class attributes that render on each read and are never stored. A trivial one-counter ThemeMixin view stored 79,862 bytes after a GET and now stores 584; the rendered page is byte-identical on first render, and the next render after a theme switch re-sends the four values (_djust_forced_changed_keys, consumed once by rust_bridge). Behaviour notes: the mixin's values still win over a theme_context processor's, as the attributes did; theme_context is not needed for {{ theme_head }} on a ThemeMixin view; an assignment in a subclass (self.theme_head = ...) still takes precedence, and, as any assignment, makes it state; a restore skips the four strings a session written by an older release holds (_state_restore.class_member_kind treats a _djust_framework_derived attribute like a property). Breaking, for code that reached the old attributes through the context: get_context_data() no longer returns theme_head, theme_css, theme_switcher or theme_presets (read self.theme_head; templates are unaffected), and a view with no request renders them empty. theme_presets_count is still supplied. A view that skipped mount() (reconnect restore, restored sticky child) gets the values without theme_context, from its request. theme_presets is a RequestScopedList on these views, a real list the framework does not store (#3631; first shipped here as a lazy non-list object, which had no json.dumps form). The same warning now prints the snapshot's three parts (context, rendered tree, template) with the largest context keys, and its advice matches what was measured: render fewer and leaner rows; temporary_assigns empties the context copy after the next render but not the tree; streams and get_render_only_context() do not shrink the snapshot. New cases in TestTheViewStateOmitsTheHeavyChunks, TestTheNamesStillWork, TestTheHttpPostFallback, TestTheWebSocketPath (test_theme_mixin_state_weight_3626.py), in TestAViewThatSkippedMount, TestTheDerivedNames, TestAThemeSwitch, TestTheLeftovers (test_theme_mixin_restore_and_switch_3626.py) and in TestDescribeSnapshot, TestTheAdviceMatchesWhatReducesTheNumber, TestTheWarningItself (test_large_state_warning_3626.py). Refs #3626: the context processor's own theme_presets list (~11.7 KB in any view's snapshot) is #3631, and the stored tree/stream rows are #3632.
  • The theme_context processor's theme_presets (about 12 KB) is no longer copied into every LiveView's stored state (#3631). djust copies each JSON-serialisable context-processor value into the view's Rust state, so any app that installed djust.theming.context_processors.theme_context stored the preset list in the snapshot of every view, on the HTTP GET and on every WebSocket event, whether or not a template read it. A one-counter view stored 12,208 bytes after a GET and now stores 510. The value is a RequestScopedList: a real list (isinstance(..., list), json.dumps, {{ theme_presets|json_script:"p" }}, len, indexing, in and {% for %} behave as before, in both template engines) that the bridge sends to the renderer through the raw-value sidecar instead of storing, and drops from a snapshot an earlier build stored. ThemeMixin supplies the same type, which also restores json.dumps and isinstance(..., list) on its theme_presets (the LazyThemeList it used since #3626, which had no JSON form, is gone). A processor of your own opts a large request-derived list out of the stored state with djust.request_scoped.RequestScopedList(items); a plain list is stored as before. Behaviour note: type(ctx["theme_presets"]) is list is now False, and yaml.safe_dump of it raises RepresenterError (convert with list(...); see upgrade-to-1.3.md). The type is public: from djust.request_scoped import RequestScopedList. New cases in test_theme_context_state_weight_3631.py (TestTheStoredSnapshotOmitsThePresets, TestNothingBreaksForReaders, TestTheHttpPostFallback, TestAStaleStateEntryIsShed, TestTheWebSocketPath). Refs #3626.
  • The Large LiveView state warning now also fires when a view grows past the threshold after it was created (#3632, part 3). InMemoryStateBackend.set() was the only caller of the warning, and the view calls it once at creation (empty state) and again when a stored snapshot is re-used, so a view that grew large during its life was not reported until the next reload. refresh(), the post-render store, now reports it too: once per cache key while it stays over the threshold (not once per event), again if it drops back under and grows past it again, and with the same breakdown. In-memory backend only (Redis has no large-state warning). The warning, from set() as well, now names the state by a short hash (state-1a2b3c4d) instead of the cache key, which holds the Django session key and the request path (the older ERROR lines of the in-memory and Redis backends, for a corrupt snapshot or a failed serialisation, name the same label; they used to print the raw key); refresh(..., warn_on_large_state=False) is honoured, and a warning that a -W error filter turns into an exception is logged, not raised, inside the render path. refresh() already serialises the view on every render, so the check reuses that snapshot (len() and a set lookup); the breakdown is decoded only on the render that crosses the line. Stream rows in the stored state and stream/temporary-assign rows in the stored tree (parts 1 and 2) are not changed: clearing stream items after each render removes every row from the page on the next event (measured; test_stream_state_weight_3632.py), so those remain open in #3632. New cases in TestRefreshWarns, TestRefreshCost, TestTheRealConsumer (test_large_state_refresh_warning_3632.py). Refs #3632.
  • An HTTP-only page on the default session policy leaves its loading state on the first POST (#3635). A page that loads after its first render (start_async / assign_async queued in mount() or handle_params()) and is served to a client that never opens a socket or SSE stream stayed on its loading skeleton for good: the GET cannot run the queued work, saved the loading state in the session, and every page-POST fallback restored it without running mount()'s work. The GET now records a digest of the loading state it saved (<view key>__mount_work) when work is still owed. The first POST that finds the saved state unchanged runs mount() again, puts the saved state back on top and runs the work over it before the handler, after every refusal; the loaded state is then saved and the work is not repeated. State another writer saved in between invalidates the digest, so nothing is run twice. mount() therefore runs twice for such a page, as it does between a GET and a socket mount. A view whose mount() queued nothing records nothing.
  • sync_turn_wrapper now covers the authorization checks, on_mount hooks, connected() / disconnected(), the cursor and presence hooks and time-travel replay (#3637). These sync view hooks ran on the worker thread through a bare sync_to_async with no wrapper entry, so an app that opens a per-turn database scope in the wrapper (row-level security) had none of it there; the worst case was get_object(), which read no rows, so the object-permission check failed closed as "Access denied" for a caller who was allowed, on the socket only (the HTTP fallback was fine). Two new steps join SYNC_STEPS: "auth" (check_permissions(), Django's access mixins and replayed decorators, handler permission_required, and get_object() + has_object_permission()) and "lifecycle" (the on_mount chain, connected(), disconnected()); handle_cursor_move(), update_presence_heartbeat() and the get_presence_key() / untrack_presence() reads are "hook" steps, a time-travel replay runs its handler as an "event" step, and the remaining raw get_context_data() / get_template() / component-render hops (streamed fragments, sticky-child saves, scoped component render, actor mount, hot reload) are "render" steps. The steps are entered at the framework function that calls the hook (check_object_permission, check_view_auth, run_on_mount_hooks, ...), so every transport and every caller (mount, event, url_change, embedded and sticky children, HTTP, SSE, API) is covered by one change. Authorization steps fail closed: a wrapper that raises refuses the check, a wrapper that swallows an exception from the check or an on_mount hook can no longer turn it into a pass (it raises PermissionDenied), and the object-permission cache is written only after the step exits cleanly. _should_restore_snapshot(), _restore_snapshot() and _capture_snapshot_state() (documented subclass overrides) are "lifecycle" steps, and an async def hook is handed back unwrapped by the step helpers instead of being hidden behind a sync shim (it would otherwise never run once a wrapper was configured). A wrapper that raises on a step name it does not know (else: raise ValueError(step)) fails every mount after this change: make it ignore unknown steps (example in the turn middleware guide and the 1.3 upgrade guide). An allowlist pin fails when any callable handed to sync_to_async / asyncio.to_thread / run_in_executor is neither stepped, a verified leaf, nor on a reasoned framework-internal list, and when a documented override point has no recorded disposition. New cases in python/djust/tests/test_sync_turn_wrapper_more_hops_3637.py.
  • A handler whose only effect is a stream call now reaches the page, and temporary_assigns rows under dj-update="append" / "prepend" are added after the rows already there — over a WebSocket or SSE, a handler that only called stream_insert / stream_delete / stream_prune / stream_reset (or stream() with items) was answered with a noop, so the row waited for the next unrelated render; the same handler as a tick did not send a frame. _snapshot_assigns, the one comparison every skip decision uses, now sees a stream mutation (a _stream_epoch bumped only when a stream actually changed), while an event that touches no stream, a prune above the current size, a delete that matches no row or a reset of an empty stream stays a noop. And on a view with temporary_assigns, self.rows = [new_row] next to another change replied with a patch that had no row (the key was always left out of the changed keys), while as the only change it rewrote the first stored row and removed the rest, because the differ had no dj-update handling (only the client's full-HTML path did). The key now counts as changed once it holds anything but its reset default, and the differ keeps a dj-update container's stored children and adds the new ones by id (splice_accumulating_subtrees). Only a container whose body reads a temporary_assigns key (or streams, or an {% include %}) accumulates, found in the stored tree by its id and not by position; any other dj-update list, including one in a view that declares temporary_assigns, is re-rendered whole as before. prepend is one insert at the front per event with each row keeping its own id, attributes and dj-id. Behaviour notes: an accumulating container's rows stay in the server's copy of the page, so the stored tree grows with every row added and each event costs a little more (about 1 ms with 200 rows, 15 ms with 8,000); a row with no id is not added, and DEBUG logs a warning naming the view. (#3639)
  • sync_turn_wrapper: the teardown hooks, the private-state restore and three more missed hops now enter a step, and the structural pin that should have found them is rebuilt (#3646). Hardening follow-up to #3637. Rebuilding the pin to actually verify what it allows turned up real hops that ran an application hook (or a row read) on a worker thread with no wrapper entry: (1) a released view's teardown hooks _on_sticky_unmount(), _cleanup_on_unregister() and _cleanup_uploads() are now "lifecycle" steps wherever the release runs on a worker thread (SSE navigation, the release of a view mounted beside the page, response.close() of an unstarted stream, and a WebSocket live_redirect that discards a sticky child whose auth now fails via _preserve_sticky_children); on the event loop (the WebSocket release, the SSE stream's own close) they stay unwrapped, since a wrapper that opens a transaction raises there. A wrapper that raises on entry no longer skips a teardown hook (the failure is logged and the hook runs without the scope). (2) _restore_private_state() is a "lifecycle" step: it re-fetches private model references (Model.objects.get) and assigns with setattr, so under row-level security a private model attribute came back None after a reconnect. (3) the worker-pool server-push turn called the push handler raw in its one hop, while the stock turn enters a "hook" step. (4) a {% live_render lazy=True %} child's mount() and get_context_data() ran raw when its slot was filled; they are "mount" and "render" steps now. (5) the per-event signed-snapshot refresh called _capture_snapshot_state() raw (the mount twin was stepped in #3637). The pin (test_sync_turn_structural_pin_3646.py, moved out of the #3637 test) is keyed by (file, enclosing function, target text) instead of a bare name, so a nested helper named run / call / turn or a raw component.render no longer hides; it sees the bare @sync_to_async decorator, aliased imports, SyncToAsync, executor.submit, threading.Thread / Timer (and a Thread subclass) and anyio.to_thread.run_sync; every hand-off it allows is verified (a leaf must enter its step, a plumbing hop may not reach a stepped hook outside a step unless it declares it); and the documented-override points are derived from every class in the package, each with a checked stepped:<step> / inside:<steps>@<function> / unwrapped:<category>:<reason> disposition. The guide's "entries per request" list now includes handle_params, the second render and the POST event step (measured, and pinned by a test), and its "Not wrapped" text states which thread runs each teardown hook. Review follow-up: every inside: disposition in the hooks table is now checked by running the hook with a recording wrapper (it had asserted inside:render for hooks that a template tag calls, which run with no step open: as_live() / as_live_field(), the field_value filter, form adapters, component get_context_data() / _render_custom(), _on_sticky_update(), get_template(); of the 34 inside: entries 16 were exactly right, 7 listed steps that never ran, 3 missed that the hook also runs with no step open, and 8 named a step that was not open); those are now unwrapped:template-time and the guide has a "Not wrapped: template-time form and tag hooks" section stating that a row read there (a ModelChoiceField queryset) runs without the wrapper scope. _restore_private_state() is a guarded step, like _restore_snapshot(), so a wrapper whose __exit__ swallows an exception can no longer turn a failing private-state restore into a silent partial one. The pin also reports a functools.partial(sync_to_async, ...) factory. New cases in python/djust/tests/test_sync_turn_structural_pin_3646.py, python/djust/tests/test_sync_turn_inside_audit_3646.py and python/djust/tests/test_sync_turn_lifecycle_gaps_3646.py.
  • The limits of per-container dj-update accumulation are resolved, or documented where they cannot be — a container that renders its rows through an {% include %} is now decided by reading the included file (static names, found the way the renderer finds them, nested includes, only and with honoured; in the view's own template ./x is x at the template-dir root, as the renderer reads it), so a plain list in an include no longer swallows a removal or an edit in a view that declares temporary_assigns for another key; a dynamic name, a missing file, a cycle, a chain over 8 deep or an {% extends %} stay as before (assumed to read a key). A {% with items=rows %} alias made outside the container, and a context key that holds the same object (context["feed"] = self.rows), now count as the temporary key; a {% with rows=other %} around the container hides the key there; a {% if a > b %} before dj-update in the container's tag no longer hides it; a nested container's body belongs to the nested container, so an outer list that is an ordinary attribute stays authoritative. Rows that repeat an id in a prepend container (the mount render, or one batch) used to produce MoveChild patches the client could not apply (1/N patches failed); rows are keyed by id plus their occurrence number, so each new row is one insert at the front again. The scan no longer goes quadratic on unterminated {{ (20,000 of them took about 3 s once per class) and is bounded (1,000,000 characters, 1,000 nested elements, 2 s; over any limit the lists are re-rendered whole and a warning is logged); a timeout, an exception or an unreadable included file is not remembered (the next render scans again, up to 3 timeouts in a row; one warning per class and cause), while the size, depth and include-count bounds are; its result is dropped by the dev file watcher when any template changes, so an edited template or included file is read again without a restart. Not fixable, and now documented plainly: a value derived from the attribute (list(self.rows)), a container whose tag lives inside an included file, and a tag that reads the view without naming the key leave the container re-rendered whole, which on a temporary_assigns view keeps only the latest render's rows and lets an unrelated event blank the list. (#3647)
  • djust's own template libraries no longer log a warning per tag the native engine already handles — djust_components and theme_tags meeting djust's built-in handlers logged about 206 "already provides" warnings on the first page, burying real clashes. A refusal of a tag from a library under djust. is now logged at DEBUG; the refusal itself is unchanged, and any other library that clashes still warns once (#3655).
  • A tag that takes an object renders under the Rust engine as it does under Django (#3666). {% field_error field=form.name %} rendered nothing under the Rust engine and the error under Django: the engine hands a native handler its operands as strings and JSON, so a BoundField arrived as its widget HTML, a Form as its str, a datetime as str(datetime) and an item of a list as whatever str gives. The tags that take an object are measured, not listed by hand (scripts/lib/template_library_parity/objects.py renders every parameter of every tag with a value that records how it is used): field_error, form_errors, combobox, rich_select, relative_time, avatar_group, conversation_thread, notification_popover and toast_container. They no longer have a native handler class (rust_handlers.OBJECT_ARGUMENT_TAGS): each is registered as the {% load %} bridge handler, so the Python tag's own node renders it and the output is Django's by construction. They are registered up front, so they still work in a template that does not load the library. Behaviour change: the markup of toast_container, notification_popover, combobox, relative_time and rich_select under the Rust engine is now the Django tag's (toast_container is dj-toast-container, not toast-container), and a {% toast_container %} with no toasts is a TemplateSyntaxError as it is under Django (the handler used to read a context variable named toasts). Also behaviour changes: the six simple/inclusion tags among them now refuse an unknown keyword as Django does (see #3667), and toast_container, an inclusion tag, needs a template loader (APP_DIRS or DIRS) as it does under Django's engine, where the native handler needed none; per render, toast_container goes from about 64 to 285 microseconds for three toasts (median of 400 renders, one machine) and the other eight were measured within about 16% of the handlers they replace. Not changed: a Form or BoundField bound by {% for %} or {% with %} inside the template still reaches a tag as its str (#3755); json_viewer renders a datetime differently in the two engines, a type dispatch the attribute-read measurement cannot see (#3756); a callable operand is not called by the engine as Django would. New python/tests/test_native_handler_fidelity_3666_3667.py; the parity baseline rows for the nine tags improve.
  • Native component handlers refuse the input the Python tags refuse (#3667). Where the Python tag raised, the native handler rendered. Now a required parameter that nothing supplies is a TemplateSyntaxError at compile time with Django's message ('data_table' did not receive value(s) for the argument(s): 'rows', 'columns', djust._positional_args.missing_arguments, a transcription of the end of parse_bits); slider raises ValueError for a non-numeric value or value_end instead of echoing it into the markup; activity_feed (max), progress (value) and pagination (total_pages) raise instead of falling back to a default, and animated_number raises for a non-string separator. notification_center with notifications=None, the one row that ran the other way, renders an empty list as the Python tag does instead of raising. notification_popover with a list of strings emitted <built-in method title of str object at 0x...> into the page in BOTH engines (a str has a title, the method, read with getattr(item, "title", "")); an item attribute that is callable now reads as absent. Behaviour change: a template that relied on the lenient rendering of one of these calls, and was already invalid under Django, now raises under the Rust engine. Unchanged and documented: an unknown keyword is still accepted by the native handlers that remain (rust_only_keys in the parity baseline; dj_select reads options_var on purpose). It is NOT accepted by six of the nine tags bridged under #3666 (combobox, field_error, form_errors, relative_time, rich_select, toast_container): they take the Python tag's keywords, so {% toast_container toasts=t class="x" %} now raises Django's unexpected keyword argument where it used to render. New python/tests/test_native_handler_fidelity_3666_3667.py derives the variants one engine alone rejects, over every tag, from scripts/template-library-parity.py --fidelity.
  • The template-library bridge no longer treats tabs and split_pane as bridgeable, and no longer lets one entry decide for a whole library — _may_override ignored the raw-block registry, so djust's native raw handlers for tabs / split_pane were counted as djust_components' and bridged as a dead handler (their output is unchanged: byte-identical before and after), and a library could have displaced a djust raw-block handler. Separately, a library's module-keyed policy and #3655's "is this djust's own?" log level came from the first-registered entry, so they changed with the import order of a multi-module library and one re-exported function could classify a third-party library as djust's. Policy is now keyed on the module every entry shares, the shared-name check compares an order-independent identity, and the log level reads the refused tag's own module (#3668).
  • Review leftovers of #3535, #3652 and #3678 closed (#3691). djust.T027 now scans a template once (the line of each <Rust…/> tag was counted from the start of the template, so a template with very many tags cost time quadratic in its size) and no longer reports an example inside {% verbatim %}, which the renderer does not render as a tag; a tag in an HTML comment or a <script> body is still reported, because the renderer renders and warns for it too (a differential test against djust._rust.render_template pins that). The doc-snippet network guard in tests/test_doc_snippets_execute.py also refuses socket.gethostbyname and socket.connect_ex, which bypassed the getaddrinfo and connect guards. New pins for T027's severity, the scope of the settings-warning dedupe and the :int hint of the dj-params warning, and a fixture in python/djust/tests/test_static_render_3626.py that clears the once-per-class temporary_assigns warning registry so a "does not warn" negative no longer passes vacuously after an earlier test released the class. New cases in TestT027RustJsxTags.
  • A test that turned the worker pool on no longer leaves it on for the rest of the xdist worker — monkeypatch.setitem(config._config, ...) is restored onto a dict the config has since swapped out, so worker_threads stayed set; the per-test global reset now puts worker_threads and worker_pinning back (#3692).
  • Four intermittent python-tests failures on main were timing races in the tests, not framework bugs — each was reproduced deterministically by forcing the interleave, and none is a product defect. (1) python/djust/tests/test_view_replacement_teardown_3244_3245.py read the presence list when a replacement mount frame arrived, but a replaced view is untracked in receive()'s finally, after that frame (by design, #3254): it now waits for the consumer to finish the frame with frame_barrier (a ping/pong) in python/djust/tests/_ws_frames.py. (2) python/djust/tests/test_live_auth_denials_and_wrappers.py timed out in communicator.connect() at Channels' 1 s default, because the root conftest.py lengthened four of the seven communicator waits (#3464) and left connect, wait and get_response out; the list is now derived from the Channels classes and pinned in python/djust/tests/test_ws_communicator_defaults_3464.py. (3) python/djust/tests/test_stream_terminal_flush_3227.py passed an explicit timeout=3 that overrode the 30 s default; it now waits for the frame. (4) python/djust/tests/test_ratelimit_post_fallback.py (#3707) drained a rate=1 bucket and asserted it stayed empty, which holds only while under a second of wall clock passes; the bucket clock is now owned by the test, as it is in the other rate-limit tests that drain a bucket and assert it stays empty. The likely shared cause of (2) to (4) is a worker stalling for seconds (not witnessed in the CI logs, which carry no GC data): a full local run on Python 3.12 measured generation-2 GC pauses of median 0.32 s and up to 3.4 s in a worker holding every imported test module, and gc.freeze() when collection finishes (conftest.py) cuts that to median 0.10 s, max 1.05 s (#3692, #3707).
  • A push to a view mounted beside the page view no longer loses its target_id when worker_threads is on — the offloaded push turn hand-splices its patch frame and skipped the slot's stamp, so the client applied the patch to the page's container instead of the view's (#3692).
  • TtydTerminalView no longer 500s on a bad ?rows= / ?cols=, and no longer accepts an unbounded size — a value that is not an integer (?rows=x, ?cols=) now falls back to the view's class default instead of raising ValueError, and an integer is clamped to 1..500 rows / 1..1000 columns (a digit string too long for int() to parse is a huge number and clamps to the cap, whatever PYTHONINTMAXSTRDIGITS is set to) before it reaches the CSS calc() min-height and xterm, so a link cannot make the viewer's own tab allocate a huge terminal. ?theme= was not affected (#3699).
  • Per-match line numbers in the template checks are computed in one pass, and the doc-snippet network guard covers three more resolver calls — the text[:offset].count("\n") + 1 idiom (O(template x matches)) behind T001, S007, T004, T013, T005, T010, T011, T014, T015, T017, T025, A070, A075, A090, T016, T024, Y001-Y006, S011, djust_typecheck and scripts/check-template-comments.py is replaced by djust.checks.utils.LineCounter (and an incremental counter in the script), which reads each character once; the reported lines are unchanged. The no_network guard in tests/test_doc_snippets_execute.py now also refuses socket.gethostbyname_ex, gethostbyaddr and getnameinfo (#3705).
  • _blank_template_comments is linear in the number of {% comment %} openers. It searched for the end of each opener's line with content.find("\n", position), which runs to the end of a template that has no newline once per opener: 4x the openers cost 14x as much (0.02 s to 0.30 s for 10,000 to 40,000 {% comment %}). The next newline is now remembered while the scan moves forward past it, and 4x the openers costs 4.1x. The output is unchanged: a differential test (python/djust/tests/test_blank_template_comments_differential_3723.py) compares the new function with the old one, kept verbatim as the oracle, over 47 hand-written templates and 4,000 seeded random ones, and the growth test that was xfail(strict=True) in test_template_checks_skip_comments_3283.py is now an ordinary test.
  • Parsing a template is linear in its size when the text between tags holds no >; extract_template_variables was ~n^2.9. The cause was not the variable walk but the parser: for every {{ }} and {% if %} it decided whether the position was inside an HTML tag by walking back over the preceding tokens until it met a text token containing >. A template whose blocks are separated by whitespace (every indented template) has no such token, so each call re-read the whole prefix, and each piece was prepended to a String, which copies it. 4x the blocks cost 43x as much (0.006 s to 0.28 s for 100 to 400 blocks). One forward pass over the tokens now gives the answer for every position (html_tag_context_table, reset at the same >-bearing text tokens the walk stopped at), built lazily the first time a parse asks and found again by the token slice being parsed, so 4x the blocks costs about 4x. The renderer parses with the same function, so a cold render_template of the same template (a source not seen before) went from 36x to 3.9x for 4x the blocks; a Rust test compares every position of 5,000 random token streams (their text tokens combine fragments, so a token can hold a > and a trailing unclosed tag, </p><div class=") and 9 named shapes with the old walk, which is kept in the test module as the oracle. The growth test that was xfail(strict=True) in python/tests/test_template_variable_extraction.py is now an ordinary test over three block shapes.
  • An overriding get_context_data() ran twice per HTTP GET (#3725). The GET evaluated the view's context once and cached it for the render that follows, but the cache short-circuit sat inside the stock get_context_data(), so a view that defined its own method (with or without super()) ran its whole body a second time from _sync_state_to_rust(): queries, API calls and side effects happened twice on first paint, and a query budget read double. The render now takes the context the request already computed, through one helper (ContextMixin._context_for_render) used by both render sinks, for legacy and explicit views. Events and later renders still evaluate afresh, because the GET clears the cache. A page GET now enters one "render" sync step instead of two (the sequence in the turn-middleware guide). Not changed: the HTTP POST fallback of a legacy view still evaluates twice (#3734), and an explicit-exposure view with a template_name page shell evaluates three times on a live mount (#3733). Regression coverage in python/djust/tests/test_single_get_context_data_3725.py.
  • The dj-update accumulation scan is linear on Python versions whose html.parser is quadratic — before 3.12.11 / 3.13.5 (gh-135462, CVE-2025-6069), HTMLParser.close() rescans an unterminated <a or <!-- at the end of the input once per <, so a template ending in junk after its last container took 36s instead of milliseconds (#3647's outside any container cases on 3.12.9). The scan no longer feeds the parser what follows the last >, which can complete no tag. The bounded-scan tests in TestTheScanIsBounded now assert that 4x the input costs under 8x the CPU time (time.thread_time), not that a scan finishes within 1s of wall-clock time, so they no longer fail on a loaded machine.
  • The framework's own asgi.py examples now serve static files. djust.schema.BEST_PRACTICES["setup"]["asgi"] (what the MCP server and djust ai hand to agents) and the DjustMiddlewareStack docstring example both showed a bare "http": get_asgi_application(), the exact shape the same module lists as common mistake 5 ("static files returning 404 with ASGI servers"). Both now wrap the HTTP handler in ASGIStaticFilesHandler and call get_asgi_application() before importing djust, matching the djust new scaffold and the README (#3653).
  • The HTTP fallback now hands a start_async outcome to handle_async_result as an async step — the fallback ran the hook as a hook step, unlike the socket (#3625). The step map names it, and the pin test that lists every handle_async_result call site includes the fallback, which turned main red after #3630 and #3636 merged.
  • The README and QUICKSTART asgi.py now serve static files. Both showed "http": get_asgi_application(), so under uvicorn (the documented server) the client script at the static URL /static/djust/client.js was a 404: the page rendered Count: 0, the client never loaded, and the counter never moved. Browser-verified against the README steps applied verbatim. The blocks now wrap the HTTP handler in ASGIStaticFilesHandler, as the djust new scaffold and the installation guide already do, and call get_asgi_application() before importing djust.websocket so Django is set up first. With the fix the same project connects and counts 0 → 1 → 2 with no console errors.
  • The README hero example now runs. It set template_string = """...""", an attribute no LiveView mixin reads, so a reader who copied the first code block on the page got ValueError: Either template_name or template must be set on the first GET and on WebSocket mount. The attribute is template; the same rename is applied to every LiveView example under docs/state-management/. A new test (tests/test_readme_snippets_execute.py) executes every README block that defines a LiveView, mounts the inline-template ones through LiveViewTestClient, asserts the mounted state reaches the HTML and fires every @event_handler, so the AST-only doc-snippet check can no longer stay green over a class attribute that exists nowhere.

Security

  • Native Rust component and theme handlers read a bare positional operand's resolved text as key=value pairs, so attacker-controlled data passed as a positional argument could set any parameter of the tag; positional arguments were otherwise dropped — {% dj_tag name %} with name = "variant=primary" rendered with variant set, and {% dj_button label %} with "event=delete" could set the click event name; class, value and the other parameters were reachable the same way. Output stayed escaped, so there is no markup or script injection. It affected the native simple_tag/inclusion_tag handlers in djust_components and theme_tags under the Rust engine. The same cause made {% dj_tag "Python" %} (valid Django, label is the first parameter) render an empty <span class="tag tag-default"></span>: the handlers skipped every argument without an =. The 67 such handlers that have a positional parameter now name each positional token after the parameter the Django tag's own signature declares at that position, at template compile time, through a new optional inline-handler hook rewrite_args_at_parse(args) (djust._positional_args), so {% rating 4 %}, {% progress 25 %} and {% theme_mode_toggle "btn" False %} render as their keyword forms and a positional value is only ever a value. Django's positional checks are mirrored with its messages (received too many positional arguments, received some positional argument(s) after some keyword argument(s), received multiple values for keyword argument; Django reports a parameter given both by position and by keyword as a render-time TypeError, the Rust engine refuses it at compile time). Not covered: the raw @register.tag native handlers (alert, card, accordion, ...) are unchanged, and a bare variable whose value contains key=value can still set their parameters, in a template Django itself refuses (Unexpected argument ... Use key=value format.); that is tracked in a follow-up issue. (#3664)

  • {% avatar src=... %} no longer prints a script-capable URL, and every other URL parameter of djust's template tags and components was swept for the same gap (#3665). The Python avatar tag emitted src verbatim, so javascript:alert(1) rendered <img src="javascript:alert(1)"> under Django's engine while the native handler emitted src="#"; it now goes through url_attr(src, image=True), the policy the handler uses (data:image/... stays, any other non-navigation scheme becomes #; on a TAG a mark_safe value is the author's explicit opt-out). javascript: in an <img src> does not run in a current browser, so none of this is exploitable today; each is a missing sanitiser. The sweep (scripts/template-library-url-sweep.py) drives EVERY parameter each tag can read — not the ones named like URLs — and every parameter of every driveable class component (Component and LiveComponent, under each CSS framework), with plain, case-folded, whitespace/control-character-obfuscated, entity- and percent-encoded javascript:/vbscript:/data:text/html payloads in four argument shapes (and once with the payload under a key other arguments name), through BOTH engines, and reads the final attributes. Besides the avatar it found seven more places that printed such a URL into an <img> (eight sinks in all): the srcset of responsive_image in both engines and of the ResponsiveImage class, and five class components that escaped with html.escape where their tag twins call url_attr (ImageCropper, ImageUploadPreview, LiveIndicator, MentionsInput, OrgChart). A srcset is a list of URLs, so a new srcset_attr checks each candidate as url_attr checks one image source; candidates are split exactly as the HTML specification's "parse a srcset attribute" splits them (parentheses in a descriptor do not nest, only ASCII whitespace separates), and the tests judge it with an independent port of that algorithm, not with the production parser. The six class sinks that escaped with html.escape (ImageCropper, ImageUploadPreview, LiveIndicator, MentionsInput, OrgChart, ResponsiveImage's srcset) never honour the safe marker: a SafeString is checked and escaped like any other string, exactly as html.escape treated it before (an already-escaped &amp; becomes &amp;amp;). The other class components (Avatar, Carousel, NavMenu, Breadcrumb, ResponsiveImage's src, the LiveComponents) already honoured it and still do, as do the template tags: a mark_safe URL is the author's explicit opt-out. Behaviour changes: a blob: image URL, which the native handlers already printed as #, is now # in the Python avatar tag and in these class sinks too; a srcset with no candidate ("", whitespace) is now omitted by every responsive_image path (Django's engine used to print srcset=""); a None avatar no longer renders a broken <img> in MentionsInput; and djust_theming's URL denylist also refused the obsolete livescript: and mhtml: (#3677 then replaced that denylist with the djust_components allowlist). No href, action or other navigation sink emitted a script URL in either engine. The sweep also showed the theming tags passing every scheme the djust_components allowlist rejects (blob:, file:, custom schemes, ...); that was left for a decision and is fixed by #3677, which moves theming onto the allowlist. New cases in test_avatar_src_scheme_3665.py, test_image_url_sinks_3665.py and test_url_scheme_sweep_3665.py; the parity baseline's avatar row moves (escaping 8 -> 6, other-attrs 3 -> 1) because the two engines now agree on the src.

  • Native Rust handlers of raw @register.tag component tags read a bare variable's value as key=value pairs, so data passed as a bare operand could set a parameter of the tag — {% alert val %}body{% endalert %} with val = "title=PWNED type=danger" rendered the title PWNED type=danger, and {% card val %} / {% accordion val %} could be made to set class / id. Output stayed escaped, so there is no markup or script injection, and it needed a template Django itself refuses (Unexpected argument 'val'. Use key=value format.). Measured on the djust_components library: 134 native handlers belong to raw tags; Django refuses a bare variable on 125 of them, and 117 of the 134 took the injected parameter. Each such handler is now wrapped at registration (with_argument_contract, djust._positional_args) so that at template compile time the tag's own Django compile function checks the tokens through the existing validate_at_parse hook, stopping where the tag asks for its body (no Rust change). The Rust engine therefore refuses a bare variable, attribute path, filtered variable, literal or number on those 125 tags with Django's message as a TemplateSyntaxError, before any context exists; the keyword form title=val is unchanged, and the 9 raw tags whose compile function never reads its arguments (menu_divider, timeline, ...) still accept and ignore a bare token, as Django does. Behaviour change: a template that put a bare operand on one of these tags rendered under the Rust engine and is now a TemplateSyntaxError; it was already invalid under Django. (#3676)

  • djust_theming now sanitises every URL with the same allowlist djust_components uses, instead of its own denylist (#3677). The theming tags kept a denylist (javascript:, vbscript:, data:, and since #3665 livescript: and mhtml:), so every scheme it did not name passed through the 19 sinks the sweep reaches in both engines (the tests pin 21): theme_button, theme_nav, theme_nav_group, theme_nav_item, theme_breadcrumb, theme_avatar src, and the action and *_url of every theme_*_page that has them. blob:, file:, jar:, view-source:, intent:, ms-word:, feed:, ws:, git: and custom application schemes are not script schemes in a current browser, so none of this is a script-execution hole today; a denylist ages badly, though, since every new dangerous scheme is a hole until someone adds it. The theming helpers now call a new djust.components.utils.url_allowed (the decision safe_url already made, now callable without its escaping) and the theming denylist is deleted. url_attr and safe_url behave as before and the components allowlist is not widened. What happens to a URL whose scheme is not http, https, ftp, ftps, mailto or tel (a relative URL is fine) depends on the sink:

    • Data-derived sinks (theme_nav, theme_nav_group, theme_nav_item, theme_sidebar_nav, theme_breadcrumb items, theme_pagination's url_pattern, theme_avatar src (an inline data:image/... stays), and every theme_*_page action and link): #, as before. For example sms:, geo:, webcal: and app deep links such as myapp://record/42 now render # in these tags; they passed through before. This is the behaviour change for theme authors.
    • Developer-supplied literals ({% theme_button href=... %}, and href, src, action or formaction passed through **attrs, for example to theme_input): a script scheme (javascript:, vbscript:, data:, livescript:, mhtml:, in any spelling a browser reads as that scheme) still raises ValueError, exactly as before. Any OTHER rejected scheme renders # and logs a warning naming the parameter, not the URL; it does not raise, because these sinks rendered such a URL before and href=record.url is data, so a webcal: or blob: value must not turn the page into a 500.
    • mark_safe URLs get no exemption in any theming sink: a marked-safe URL is made plain text before the template prints it, so it is judged and escaped like any other string, as safe_url does (an already-escaped &amp; therefore prints as &amp;amp;). Before, a template printed it raw, and mark_safe("&#x6A;avascript:x"), which the allowlist cannot see as a scheme, reached the page as a javascript: URL to a browser.

    The URL-scheme sweep (scripts/template-library-url-sweep.py) now reads every scheme outside the allowlist, not only the script schemes, with 20 new payloads in the forms a browser still reads as that scheme; run over the tags with the test corpus on main it reported 38 holes in the theming tags (19 sinks in each of the two engines), and the sweep test finds none after the change. python/tests/test_theming_url_allowlist_3677.py drives every theming sink in both engines and pins the set of sinks from the templates (every URL-bearing attribute, composite values included) and from the tag functions, so a new sink is a visible decision. That wider scanner also found one gap in djust_components, recorded and pinned, not changed here: the Rust Avatar (crates/djust_components/src/simple/avatar.rs) does not strip a leading C0 control character before reading the scheme, so \x01blob:... reaches an <img src> where the Python url_attr returns #. New cases in test_theming_url_allowlist_3677.py (replaces test_theming_url_denylist_3665.py), test_theming_url_scheme_guard.py and test_url_scheme_sweep_3665.py.

  • TtydTerminalView took the WebSocket its terminal connects to straight from ?url=, so a crafted link could point a victim's terminal at any server — the browser sends every keystroke to that endpoint and draws its replies as terminal output, so an attacker's server could show a fake prompt and record what was typed. It is not a script scheme, so this was not script injection. ?url= is now honoured only for a ws:// or wss:// URL whose origin (scheme, host and port, compared exactly) is the view's own ttyd_url or is listed in the new ttyd_allowed_origins class attribute, and only if it carries no query string (a ttyd started with --url-arg turns URL arguments into command-line arguments of the spawned process); anything else is ignored, with a warning that does not echo the value, and the view falls back to ttyd_url. The URL the browser receives is rebuilt from the allowed origin plus the requested path rather than echoed, and a value containing a backslash, whitespace, a control or non-ASCII character, userinfo, no host or more than 2048 characters is refused outright. A subclass's own ttyd_url is trusted as written and may carry a query. Behaviour change: /shell/?url=ws://other-host:7681 no longer reaches other-host unless it is listed in ttyd_allowed_origins (or is the view's own ttyd_url). TtydTerminalView is also added to the packages the #3665 URL sweep walks (pinned as an undriven page) so a renderable class outside the swept packages is no longer invisible to it (#3679).

  • pulldown-cmark 0.13.5 → 0.13.6 (RUSTSEC-2026-0433, #3750). The advisory is quadratic parsing of nested image wikilinks under ENABLE_WIKILINKS. djust_templates enables only the html feature and never sets ENABLE_WIKILINKS, so the option the advisory names is not reachable from djust's markdown filter; the bump clears cargo audit --deny unsound, which security-scan runs. Lockfile only.

  • Lock: autobahn 26.7.1 -> 26.10.2 (CVE-2026-116579) — autobahn is reached only through the optional djust[daphne] extra (and the dev environment). The lock now constrains it to the fixed release (>=26.10.1) and cbor2 follows it to 6.1.5 on CPython. This constrains uv.lock, not the published metadata, so installers of djust[daphne] resolve their own latest autobahn.

  • The HTTP fallback refuses an event the page view has no handler for, and view is a reserved state field name. Two low-severity findings from the 2026-10 explicit-path probes. (1) A POST naming an event that resolved to no attribute, or to a non-callable one, on the page view fell through dispatch and was answered with 200, a full render and a state save as if a handler had run; the socket transports and the component branch already answered 400. The page view now answers {"error": "Event handler not found"} with 400 and writes nothing. The event-name guard also used $, which matches before a trailing newline, so "bump\n" passed the syntactic check on every transport; it now uses \Z. (2) ExposureContract accepted view as a state() field name although the explicit render context reserves it for the view object; it is now rejected like the other reserved names. New cases in test_http_unknown_event_refused.py, TestEventGuard and test_invalid_field_names_are_rejected; seven existing tests that had pinned the 200 fall-through for an unknown event (three HTTP-fallback background-work tests, three sticky-child HTTP tests that now drive a handler the parent defines, and TestErrorHandling.test_invalid_event_handler) now expect the refusal.

  • Lock: pyasn1 0.6.4 -> 0.6.5 (CVE-2026-119194, CVE-2026-119195, CVE-2026-119196, CVE-2026-119197) — pyasn1 is a lock-only constraint; it is raised to >=0.6.5, the release that fixes the four advisories pip-audit now reports. This constrains uv.lock, not the published metadata.

  • pulldown-cmark 0.13.4 → 0.13.5 (RUSTSEC-2026-0414) — the advisory is an exponential event growth with ENABLE_WIKILINKS, which djust never sets (markdown.rs enables tables, strikethrough and task lists only), so it was not reachable; the bump clears the weekly vendor scan that had been failing on main. Cargo.lock and the SBOM python/djust/djust.cdx.json are regenerated.

Documentation

  • Roadmap: v1.3.0-10 drain bucket — adds the HTTP fallback parity, sync-turn-wrapper coverage and theme state weight issues (#3624, #3503, #3625, #3626, #3482) to the v1.3 drain plan, with the shipped rows marked done.
  • Roadmap: v1.3.0-14 drain bucket — records the follow-ups of the v1.3.0-13 drain (HTTP fallback, sync-turn-wrapper hops, dj-update accumulation) and renames the earlier colliding v1.3.0-10 bucket to v1.3.0-13.

Deprecated

  • Everything djust 2.0 removes now warns first (#3500). Each warning goes through warn_deprecated, names its replacement and says it is removed in 2.0.0; docs/API_STABILITY.md lists them all.
    • AccordionMixin, TabsMixin, ModalMixin, CollapsibleMixin, SheetMixin, DropdownMixin, TooltipMixin and CarouselMixin warn when a view subclasses them. Use the matching component in djust.components.descriptors.
    • @reactive warns where it is applied. It cannot work on a LiveView, which has no update() (the decorator raises TypeError there), and on other hosts the decorated function's body is never called. Use a plain attribute or @computed.
    • social_auth_providers (deprecated in 1.3) now warns in the standard form, with a removal version.
    • @event, LiveViewForm and the _legacy theme module, whose warnings said "no earlier than 1.1.0", now name 2.0.0.
    • The module shims djust.template_backend and djust.state_backend warn on import, djust._view_resolution.is_view_import_allowed and djust.rust_components.Button warn when called, and djust.simple_live_view.LiveView warns on access. The scaffolder, the framework's own imports and the test suite use the real paths.
    • The SSE /event/ endpoint (DjustSSEEventView) warns on each POST.
    • StreamingMixin's async stream_insert / stream_delete gain the names astream_insert / astream_delete. On a LiveView the old names were shadowed by the sync collection-stream methods and reachable only as StreamingMixin.stream_insert(self, ...), which now warns.
    • At startup, DjustConfig.ready() warns once for each deprecated setting in use: DJUST_WS_COMPRESSION, DJUST_VDOM_CACHE_*, DJUST_STATE_BACKEND, DJUST_REDIS_URL, the template_auto_call / virtual_keyed_ops / django_value_repr keys, and a TEMPLATES entry naming djust.template_backend.DjustTemplateBackend. They keep working in 1.x; 2.0 refuses to start while one is set.
  • Defining _drain_announcements or _drain_focus on a LiveView is deprecated (#3629, follow-up to #3503). The WebSocket consumer and the SSE runtime drain these two duck-typed hooks behind hasattr and send an accessibility / focus frame, but nothing in djust defines them, so no built-in API ever queued an announcement or a focus command, and the shipped client has no window.djust.accessibility object to receive the frames unless the application's own script assigns one. The HTTP fallback never drained them, so a view that defined them lost them there silently. The frames are wire-protocol and djust.d.ts types the client object, so rather than delete the flush (or add a producer and a client half for a feature the 2.0 line has already removed, #3539) a view class that defines either hook (in its own body or through a mixin) now emits one DeprecationWarning per hook at its class statement, naming the removal (no earlier than 2.0.0) and the replacement: a role="status" live region the view renders, and JS.focus() through push_commands(), which every transport carries (a live region announces only a changed text, so a repeated identical message needs the alternating-suffix workaround in the guide). Behaviour is unchanged until 2.0.0. New: an "Announcing a change and moving focus from the server" section in the accessibility guide and an entry in API_STABILITY.md.
  • Client APIs that djust 2.0.0 removes now warn once per page when used. The JSON dj-params attribute (use dj-value-* attributes; the warning used to appear only with djustDebug), the pre-1.0 [data-djust-root] marker in dj-layout swaps (use dj-root), and the window.LiveViewWebSocket, window.setCacheConfig and window.globalLoadingManager aliases (use the same names on window.djust). Each warning names its replacement, and each API keeps working until 2.0.0. client-dev.js, the demo loading test page and the Playwright loading check now read window.djust.*, so they don't trigger the warning. Covered by tests/js/deprecation_warnings_2_0.test.js.
  • JSX-style <Rust…/> component template tags. Rendering a tag such as <RustButton id="b" label="Go" /> now emits a DeprecationWarning: the tags are deprecated since 1.3 and removed in 2.0.0, where they become a template error. Use the matching djust.components.ui component. 3 regression cases in python/tests/test_rust_component_tag_deprecation.py.

All releases · Atom feed