djust 0.9.0rc3

Pre-releaseSecurityReleased

This is a pre-release. djust 0.9.0 has shipped since: read the djust 0.9.0 release notes.

Fixed

  • v0.9.3 test-infra cleanup — suppress unhandled errors in JS + Python test runtimes (closes #1186, closes #1152, closes #1153) — release-blocker for v0.9.0rc3. Three test-runtime warnings/errors that surfaced during local make test but never affected production behavior, all unblocking the canonical exit-0 gate:
    • #1186 (P1): happy-dom + undici WebSocket dispatchEvent cross-pollination — undici fires a Node-side Event that happy-dom's EventTarget.dispatchEvent runtime check rejects (the two runtimes don't share a Web-platform Event prototype). Filtered via a new onUnhandledError hook in vitest.config.js matching a narrow message + stack pattern. Anything outside the pattern still re-throws.
    • #1152 (P2): view-transitions.test.js non-deterministic teardown EnvironmentTeardownError: Closing rpc while "onUserConsoleLog" was pending. Stubs already yielded a microtask per CLAUDE.md retro #1113, so the diagnosis was RPC-timing teardown noise, not a stub regression. Filtered via the same onUnhandledError hook.
    • #1153 (P2): real lifecycle bug in python/djust/mixins/template.pyarender_chunks, not warning suppression. task.cancel() only signals cancellation — it doesn't unblock done.get() inside asyncio.as_completed's internal _wait_for_one. When arender_chunks returned mid-loop on emitter.cancelled, the for-protocol's already-pulled coroutine plus any further iterator-yielded coroutines were GC'd unawaited and Python emitted RuntimeWarning: coroutine '_wait_for_one' was never awaited. Fix: explicit _drain_iterator(as_completed_iter) after _cancel_pending() so the iterator's queue empties cleanly. Regression test test_cancel_does_not_leak_wait_for_one_warning in tests/integration/test_chunks_overlap.py asserts no _wait_for_one warnings via warnings.catch_warnings (1 new case).
    • Three consecutive make test runs exit 0 post-fix (was non-deterministic 1-3 unhandled errors out of 1463 passing JS tests + 4047 passing Python tests).
  • {% data_table %} row navigation polish — 3 sub-items from PR #1170 Stage 11 review (closes #1171) — final v0.9.2 drain item; tightens the row-navigation client module that shipped in #1170:
    • (a) Nested-control selector — add <details>/<summary>/<option> (R3).NESTED_CONTROL_SELECTOR was 6 tags (a, button, input, label, select, textarea); missed three common interactive elements. Disclosure widgets (<details>) and <select> children (<option>) now suppress row navigation when the user toggles or selects them. Pure additive selector change, no behaviour change for existing markup.
    • (b) Test-hook namespace refactor — drop window.__djustRowClickNavigate (R4). Production code now dispatches through window.djustDataTableRowClick.navigate, which is also the property tests stub via direct assignment (vi.fn). The underscored magic global is gone — cleaner contract; the namespace was already exported for bindRow / initAll in #1170.
    • (c) Server-side contract test for URL allowlist (R5). New tests/unit/test_data_table_url_allowlist_1171.py parametrizes 6 URL shapes (3 allowed, 3 hostile — //evil.com, javascript:..., data:...) and locks in the "render-doesn't-crash, wiring-is-stable" contract that the JS guard depends on. The actual open-redirect defense remains the regex in data-table-row-click.js; this Python test documents the server-side half of the boundary.
    • Test count delta: tests/js/data_table_row_click.test.js 14 → 17 (+3); new test_data_table_url_allowlist_1171.py 7 cases.

Changed

  • v0.9.2 hygiene group — Redis perf docstring softened, replay-rejection caplog assertions, descriptor-pattern auto-promotion gap documented, dev-env import regression guard (closes #1160, closes #1165) — Stage 11 follow-ups from the v0.9.1 retro arc, batched as a single chore PR:
    • #1160: rewrite test_redis_serialization_performance docstring in tests/unit/test_state_backend.py to match what the 100ms bound actually catches (catastrophic ~10× regressions, e.g. accidental JSON/pickle round-trip), not gradual perf drift. Points to pytest-benchmark-style median-based assertions for SLA-grade perf checks.
    • #1165 (a): extend TestReplayHandlerValidation rejection-path tests in tests/unit/test_time_travel.py to assert via caplog that the logger.warning(...) record fires with the expected message ("refused unregistered method" / "refused dunder/private event_name"). Side-effect-only assertions previously stayed green if the warning silently regressed to a no-op.
    • #1165 (b): document the descriptor-pattern auto-promotion gap in the LiveComponent docstring (python/djust/components/base.py) and in docs/website/guides/components.md. The framework's _assign_component_ids walker only inspects instance-level attrs, so descriptor components must be appended to self._components in mount() until auto-promotion ships. Time-travel snapshots and other walkers silently miss them otherwise.
    • #1165 (c): add scripts/check-dev-env-imports.py and a paired pytest module (tests/unit/test_dev_env_imports.py, 2 new parametrized cases) that hard-fail (not skip) if djust.components.components or its .markdown submodule cannot import. Locks in the #1149 fix where missing markdown/nh3 caused opaque pytest collection failures. Script is standalone for now; a follow-up PR can wire it into pre-commit / Makefile.
  • CSP-strict defaults canonicalized for new client-side framework code (closes #1175) — adds explicit guidance in CLAUDE.md, docs/PULL_REQUEST_CHECKLIST.md, and docs/guides/security.md that any new framework feature emitting HTML must default to: external static JS modules (no inline <script> blocks), no inline event handlers (no onclick=/onchange=/oninput=), auto-bind via marker class + delegated listener on document/root, CSP nonce propagation only when genuinely required (lazy-fill case from #1147 is the canonical exception). Reference-module shapes documented (PR #1170data-table-row-click.js, PR #113850-lazy-fill.js, existing 39-dj-track-static.js). v1.0 readiness — positions strict-CSP deployments as a design constraint, not an opt-in.

Added

  • {% data_table %} row-level navigation: accessibility, keyboard, and CSP-strict layer (closes #1111) — layers v0.9.1 quality additions onto the prior #1111 row-navigation scaffolding (which shipped row_click_event / row_url template-tag args, mixin defaults, and structural wiring). What's added:

    • Accessibility: every row-clickable <tr> now renders role="button", tabindex="0", and cursor:pointer. Screen readers announce the row as a button; keyboard users get focus.
    • Keyboard activation: Enter and Space on a focused row fire the configured action. Guarded by document.activeElement === tr so Space inside a nested input doesn't hijack the keystroke.
    • Nested-control guard: clicks inside <a>, <button>, <input>, <label>, <select>, <textarea> are short-circuited via capture-phase stopImmediatePropagation, so the row-level action never fires for those clicks. This is the integration point with selectable=True (per-row checkbox) and the cell-level link column (#1110).
    • CSP-strict friendly: the row_url path's previous inline onclick="window.location=this.dataset.href" is replaced by a new component JS module (python/djust/components/static/djust_components/data-table-row-click.js). No inline event handlers, no nonce plumbing — works under script-src 'self' out of the box.
    • Defense-in-depth: data-href values are regex-validated against /^(https?:|\/|\.)/ before window.location.assign, so a hostile javascript: URI cannot execute even if it sneaks into the row dict.
    • Multi-line template comments fixed: the pre-existing {# ... #} row-nav and link-column doc comments were rendering as literal text in output because Django's {# %} is single-line-only. Converted to {% comment %}...{% endcomment %}.

    New cases in TestRowClickAccessibility, TestRowClickableMarkerClass, TestRowClickAffordance, TestCSPInlineHandler, TestSelectableComposition, TestCSPNonce (tests/unit/test_data_table_row_navigation_1111.py, 14 Python cases) plus 11 JS cases in tests/js/data_table_row_click.test.js cover: role + tabindex presence, marker class on/off, no-inline- onclick (CSP), checkbox cell composition, click navigation, nested <a>/<input> guard, Enter/Space activation, activeElement guard, javascript: URI rejection, dj-click composition (capture-phase stop), and bindRow idempotence. One pre-existing structural test in python/tests/test_data_table_link_row_nav.py was rewritten to assert the new data-table-row-clickable marker class instead of the removed inline onclick.

  • Theming cookie namespace for per-project isolation on shared domains (closes #1158) — adds opt-in LIVEVIEW_CONFIG['theme']['cookie_namespace'] setting so multiple djust projects on localhost:80xx (or any shared domain) don't overwrite each other's theme preferences. Browsers scope cookies by domain only — not by port — so the four djust_theme* cookies bleed across projects without this. PR #1013 already shipped enable_client_override: False as a workaround, but that breaks sites with a user-facing theme switcher; this is the missing piece for those sites. When cookie_namespace="djust_org" is set, the cookies become djust_org_djust_theme, djust_org_djust_theme_preset, djust_org_djust_theme_pack, djust_org_djust_theme_layout. Read path tries namespaced first, falls back to unprefixed once on upgrade so users keep their existing theme. Write path (theme.js) reads window.__djust_theme_cookie_prefix injected by theme_head.html and writes only the namespaced name when set. When unset (default), the legacy unprefixed names are used — existing deployments unaffected. 8 new regression cases in tests/unit/test_theming_cookie_namespace_1158.py cover namespaced precedence, unprefixed fallback, default back-compat, two-namespace isolation, all four cookies honour the namespace, and the theme_head.html + theme.js write-side wiring.

  • Rust template engine {% live_render %} lazy=True parity (closes #1145) — the Rust template engine now ships a registered handler for {% live_render %}, closing the v0.9.0 PR-B (#1138) gap. Before this, production users on RustLiveView got a "no handler registered for tag: live_render" template error if they used lazy=True, forcing a fallback to the slower Django engine to use streaming. The Rust handler delegates to the existing Python implementation in djust.templatetags.live_tags.live_render, so behaviour is byte-for-byte identical on both paths — same <dj-lazy-slot> placeholder shape, same thunk-stash side effect on parent._lazy_thunks, same CSP nonce propagation, same sticky=True + lazy=True collision raise. The bridge required threading the raw Python sidecar (request, view) through to the custom-tag handler context: crates/djust_core exposes Context::raw_py_objects() for read access, and crates/djust_templates::registry adds call_handler_with_py_sidecar (a backward-compatible variant of call_handler — existing handlers ignore the extra Python objects in their dict). 8 parity regression cases in tests/unit/test_rust_live_render_lazy_1145.py cover lazy=True placeholder byte equivalence, lazy="visible" parity, thunk stash on the Rust path, CSP nonce parity, sticky+lazy collision, the inline-attribute template = "..." mode (the original failure surface from PR #1138 integration tests), and eager-mode regression-guard.

  • A075 system check: {% live_render sticky=True lazy=True %} collision (closes #1146) — promotes the existing tag-eval-time TemplateSyntaxError to a startup-time warning so the misuse surfaces during manage.py check instead of waiting for a request to render the offending template. Sticky preservation requires the slot to exist at mount-frame time so the WebSocket reattach can replaceWith the stashed subtree; lazy=True defers slot rendering until after the parent shell flushes — the stash target doesn't exist when reattach runs. The check skips {% verbatim %}...{% endverbatim %} regions so docs/marketing pages showing the anti-pattern as a literal example don't false-positive (re-uses the _strip_verbatim_blocks helper from the v0.7.3 #1004 fix). Silenceable per-project via DJUST_CONFIG = {"suppress_checks": ["A075"]}. 8 regression cases in TestA075StickyLazyCollision cover collision firing, sticky-only / lazy-only silence, verbatim suppression, real-call next to verbatim example, config disable knob, and string-truthy kwarg shapes.

Security

  • CSP-nonce-aware activator for <dj-lazy-slot> fills (closes #1147){% live_render lazy=True %} now propagates request.csp_nonce (the Django convention set by django-csp middleware) onto BOTH the <template id="djl-fill-X"> element AND the inline <script> activator that calls window.djust.lazyFill(...). Sites with strict CSP (script-src 'nonce-...', no 'unsafe-inline') previously had the activator silently rejected at parse time, and lazy children never mounted. The fix reads getattr(request, 'csp_nonce', None) via the existing djust.utils.get_csp_nonce helper — no additional configuration is required for any CSP middleware that follows the Django convention. When request.csp_nonce is absent or empty (the common case for sites without CSP middleware), no nonce attribute is emitted — backward-compatible for non-CSP deployments. The placeholder <dj-lazy-slot> also carries the nonce so client-side code can read it via getAttribute('nonce') if it ever needs to inject CSP-bound scripts under the same policy. 6 Python regression cases in tests/unit/test_lazy_render_csp.py
    • 3 JS cases in tests/js/lazy_fill_csp.test.js cover nonce propagation, backward compatibility (no nonce attr when csp_nonce is absent / empty / missing), and HTML-escaping defense-in-depth for hostile-middleware substitutes.

Changed

  • Dev-deps include markdown and nh3 (closes #1149) — both packages are runtime deps of the [components] extra (see python/djust/components/components/markdown.py) and the components subpackage's __init__.py eagerly imports them via from .markdown import Markdown. Tests that import djust.components.components (directly or transitively) failed collection in clean checkouts that ran only uv sync without the [components] extra. The bisect agent in PR #1159 hit this on a fresh clone. Added both to [project.optional-dependencies.dev] so a single uv sync --extra dev brings them in alongside the rest of the test toolchain. No behaviour change for runtime users — [components] already lists both as runtime deps.

Fixed

  • Theming cookie namespace polish — 4 sub-items (closes #1169) — Stage 11 follow-ups from PR #1168 (the original cookie-namespace work for #1158):

    • (a) Empty namespaced cookie no longer falls back to legacy.ThemeManager.get_state() previously evaluated the namespaced cookie via _read('<ns>_name') or None, so an empty-string value ("") silently fell through to the unprefixed legacy cookie — re-opening the cross-project bleed path #1158 closed. The read now distinguishes None (cookie not in jar) from "" (cookie set to empty), and only falls back in the former case.
    • (b) cookie_namespace validated at config-load. The value is interpolated directly into cookie names; whitespace, =, ;, and non-ASCII characters previously produced malformed Set-Cookie headers (browsers reject or split such cookies). _validate_cookie_namespace() now raises ImproperlyConfigured at startup for any value outside [A-Za-z0-9_-]+.
    • (c) JSDOM tests for the cookie WRITE side. The 8 #1158 Python tests only asserted on theme.js source-text patterns; new tests/js/theming_cookie_namespace_write.test.js loads the file in JSDOM, sets window.__djust_theme_cookie_prefix, fires setPack/setPreset/setLayout, and inspects document.cookie.
    • (d) Legacy-cookie cleanup on first namespaced write. When cookie_namespace is set, every theming-cookie write in theme.js now also emits Max-Age=0 for the unprefixed legacy name. Stale legacy cookies left over from before namespace was configured no longer sit in the jar forever and bleed back if the namespace is later removed. Cleanup is inert when no prefix is configured (back-compat).

    3 new regression cases in tests/unit/test_theming_cookie_namespace_1158.py (1 for sub-item (a), 2 for sub-item (b)) plus 7 new JS cases in tests/js/theming_cookie_namespace_write.test.js (4 for sub-item (c), 3 for sub-item (d)).

  • Tag-registry test isolation + sidecar bridge extension to block / assign tags (closes #1167) — two Stage 11 follow-ups from PR #1166 (which wired the raw-Python sidecar into Node::CustomTag):

    • Test isolation: tests/unit/test_tag_registry.py previously used per-class setup_registry fixtures that re-registered the Python built-in handlers on teardown but did NOT clear the global Rust TAG_HANDLERS registry first. Transient handlers from the file (notably BrokenHandler registered for the broken tag in test_handler_exception_returns_error) leaked into subsequent test files. test_assign_tag.py running after this file would see handler_exists("broken") == True; the parser dispatches handler_exists before assign_handler_exists so {% broken %} was routed to the leaked CustomTag handler and test_non_dict_return_is_empty_merge failed with the leaked handler's exception. Fix: replace the per-class fixtures with one function-scoped autouse fixture that clears all three Rust registries (tag / block-tag / assign-tag) before AND after every test, then re-registers the built-ins from djust.template_tags._registered_handlers. The file is now self-contained.
    • Sidecar parity: PR #1166's call_handler_with_py_sidecar only fired for Node::CustomTag. Block tags (Node::BlockCustomTag) and assign tags (Node::AssignTag) didn't receive the request / view sidecar, so a custom block or assign handler couldn't reach the parent view. Added call_block_handler_with_py_sidecar and call_assign_handler_with_py_sidecar mirroring the PR #1166 pattern; the existing variants are kept as back-compat shims that delegate with None. All five renderer call sites (1× block, 4× assign — single-node, sibling-aware, collecting, and partial-render paths) forward context.raw_py_objects().

    New cases in TestBlockTagSidecar and TestAssignTagSidecar (tests/unit/test_tag_sidecar_parity_1167.py, 6 Python cases) cover sidecar receipt of request and view per node type plus a back-compat regression per node type confirming legacy handlers that ignore the sidecar continue to work unchanged.

  • Custom filter bridge polish — 6 sub-items deferred from #1161 (closes #1162) — Stage 11 review of PR #1161 (which closed #1121 by adding the eager Rust filter registry) flagged six follow-ups. All are addressed in this PR:

    1. Hot-path Mutex perf: is_custom_filter_safe and apply_custom_filter short-circuit on a new ANY_CUSTOM_FILTERS_REGISTEREDAtomicBool so projects with no custom filters pay only an atomic load on every variable expansion's filter_specs.iter().any(...) loop, never a Mutex acquire. Acquire/Release ordering pairs the load with the store in register_custom_filter.
    2. Hardcoded autoescape=True plumbing (correction, #1180): apply_custom_filter now accepts an autoescape: bool parameter that's set as a kwarg on the Python callable when the filter declares needs_autoescape=True. The earlier wording here was inaccurate — only apply_custom_filter was widened; the upstream chain (apply_filter_full in filters.rs and the renderer's three call sites at renderer.rs:287, 349, 1602) was NOT threaded through. Future {% autoescape %} block tracking will need to update ~4 sites to plumb the dynamic value end-to-end, not 1.
    3. Unknown-filter test tightened: assert RuntimeError type AND the canonical "Unknown filter:" message shape, not just pytest.raises(Exception) + substring on filter name only.
    4. Dropped unused custom_filter_exists: dead public Rust function with no callers in the workspace; PyO3 macros suppress the dead-code warning so it would have rotted silently.
    5. Fixture isolation comment: the scope="module" autouse fixture in tests/unit/test_rust_custom_filters_1121.py now carries an explicit comment that this file is not safe to run in parallel with other Rust-filter-registry-touching tests.
    6. Silent async filter handling: an async def custom filter previously stringified the unawaited coroutine ("<coroutine object ...>") into the rendered HTML with a "coroutine was never awaited" RuntimeWarning at GC. Now uses inspect.iscoroutine to detect and reject with a clear, actionable error and coro.close() to suppress the GC warning.

    New cases in TestNewBehavior_1162 (tests/unit/test_rust_custom_filters_1121.py, 2 Python cases) cover async-filter rejection (sub-item 6) and autoescape kwarg flow (sub-item 2). Two new Rust unit tests in filter_registry::tests cover the AtomicBool short-circuit pre-registration.

  • replay_event validates handler is @event_handler-decorated (closes #1148) — defense-in-depth strengthening of the v0.9.0 #1042 forward-replay path. The original guard rejected only dunder/private event_name (startswith("_")), which still admitted ANY public method on the view — helpers, inherited utilities, property getters — even though the dispatcher only ever invokes @event_handler-decorated methods. A hand-edited or malicious snapshot could replay e.g. view.delete_all_records() even when that method was never exposed to the dispatcher. The fix calls djust.decorators.is_event_handler(handler) after attribute resolution, mirroring the dispatcher's own acceptance criteria (see websocket.py ~ line 4389 server_push handler validation). Unregistered methods log a warning and return None instead of invoking. 3 regression cases in TestReplayHandlerValidation cover registered-handler success, unregistered-method rejection, and the existing dunder-rejection regression.

  • Rust template renderer rejects project-defined custom filters (closes #1121) — Django projects registering custom filters via @register.filter in their templatetags/ modules saw them work in the Python render path but fail under the Rust RustLiveView render path with RuntimeError: Template error: Unknown filter: <name>. The Rust engine's filter dispatch was a hardcoded match against Django's 57 built-in filter names with no fallback for project-level filters. The fix is a Python→Rust bridge mirroring the existing custom-tag-handler design (crates/djust_templates/ src/registry.rs):

    • New crates/djust_templates/src/filter_registry.rs holds a process-wide Mutex<HashMap<String, FilterEntry>> of project filter callables + per-filter metadata (is_safe, needs_autoescape).
    • The renderer's filter loop forwards an arg_was_quoted hint from the parser so the bridge can resolve bare-identifier args against the template context before calling Python — fixing the {{ my_dict|lookup:some_key }} shape from the issue body.
    • Both filter.is_safe and filter.needs_autoescape from the Django filter object are honoured: is_safe=True filters skip auto-escape; needs_autoescape=True filters receive autoescape=True as a kwarg.
    • python/djust/template_filters.py walks template.engines['django'].engine.template_libraries at the first LiveView render and bulk-registers every custom filter found. Built-in Django filter names are skipped (the Rust engine has native implementations of all 57). The bootstrap is idempotent — late-loaded apps' filters are picked up on subsequent renders.
    • Unknown filter names still raise the original Unknown filter: <name> error so typos and missing imports surface immediately. 10 regression cases in TestRustCustomFilters cover the lookup shape from the issue body, is_safe, needs_autoescape, quoted vs context- resolved args, plain-text auto-escape, and the full RustLiveView render path.
  • Test pollution: 6 flaky tests in full-suite pytest run (closes #1134) — bisected two independent polluters that surfaced after v0.9.0 PR-A (#1135) added the aget/ChunkEmitter async-render path and after PR #998 added the block_watchdog test fixture:

    • In-memory SQLite + Channels disconnect: 5 tests (test_websocket_origin_validation::TestConnectOriginValidation's 4 accepting-handshake cases + test_request_path::test_websocket_mount_counter) failed during communicator.disconnect() because Channels' consumer dispatch invokes aclose_old_connections(), which iterates Django's connection cache and calls close_if_unusable_or_obsolete()get_autocommit()ensure_connection(). SQLite ignores close() for in-memory DBs (data-loss prevention), so a prior django_db-marked test leaves the connection wrapper with .connection != None in the thread-local; pytest-django's blocker then fires inside the consumer's cleanup. Marked the affected tests @pytest.mark.django_db so they participate in pytest-django's connection management.
    • sys.modules["djust.checks"] rebind: the test_dev_server_watchdog_missing.py::test_check_hot_view_replacement_survives_without_watchdog test deleted djust.checks from sys.modules and re-imported, creating a new module object while test_static_security_checks.py had already done from djust.checks import check_configuration at collection time. Subsequent mock.patch("djust.checks._has_multiple_permission_groups", ...) targeted the new module while the old check_configuration kept resolving names against the old module's __dict__ — so the patch silently no-op'd and test_a020_fires_with_multiple_groups failed. Moved snapshot/restore of djust.checks and djust.dev_server into the block_watchdog fixture's setup/ teardown so the eviction is local to the test's lifetime.
    • Redis-serialization-performance 10ms wall bound: relaxed the bound from 10ms to 100ms — under heavy full-suite load (GC pauses, scheduling jitter) the ideal-conditions 10ms ceiling was producing false positives. 100ms still catches "we accidentally serialized via JSON/pickle round-trip" regressions without the timing flake.

All releases · Atom feed