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 testbut never affected production behavior, all unblocking the canonical exit-0 gate:- #1186 (P1): happy-dom + undici WebSocket
dispatchEventcross-pollination — undici fires a Node-sideEventthat happy-dom'sEventTarget.dispatchEventruntime check rejects (the two runtimes don't share a Web-platformEventprototype). Filtered via a newonUnhandledErrorhook invitest.config.jsmatching a narrow message + stack pattern. Anything outside the pattern still re-throws. - #1152 (P2):
view-transitions.test.jsnon-deterministic teardownEnvironmentTeardownError: 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 sameonUnhandledErrorhook. - #1153 (P2): real lifecycle bug in
python/djust/mixins/template.pyarender_chunks, not warning suppression.task.cancel()only signals cancellation — it doesn't unblockdone.get()insideasyncio.as_completed's internal_wait_for_one. Whenarender_chunksreturned mid-loop onemitter.cancelled, the for-protocol's already-pulled coroutine plus any further iterator-yielded coroutines were GC'd unawaited and Python emittedRuntimeWarning: 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 testtest_cancel_does_not_leak_wait_for_one_warningintests/integration/test_chunks_overlap.pyasserts no_wait_for_onewarnings viawarnings.catch_warnings(1 new case). - Three consecutive
make testruns exit 0 post-fix (was non-deterministic 1-3 unhandled errors out of 1463 passing JS tests + 4047 passing Python tests).
- #1186 (P1): happy-dom + undici WebSocket
{% 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_SELECTORwas 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 throughwindow.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 forbindRow/initAllin #1170. - (c) Server-side contract test for URL allowlist (R5). New
tests/unit/test_data_table_url_allowlist_1171.pyparametrizes 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 indata-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.js14 → 17 (+3); newtest_data_table_url_allowlist_1171.py7 cases.
- (a) Nested-control selector — add
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_performancedocstring intests/unit/test_state_backend.pyto match what the 100ms bound actually catches (catastrophic ~10× regressions, e.g. accidental JSON/pickle round-trip), not gradual perf drift. Points topytest-benchmark-style median-based assertions for SLA-grade perf checks. - #1165 (a): extend
TestReplayHandlerValidationrejection-path tests intests/unit/test_time_travel.pyto assert viacaplogthat thelogger.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
LiveComponentdocstring (python/djust/components/base.py) and indocs/website/guides/components.md. The framework's_assign_component_idswalker only inspects instance-level attrs, so descriptor components must be appended toself._componentsinmount()until auto-promotion ships. Time-travel snapshots and other walkers silently miss them otherwise. - #1165 (c): add
scripts/check-dev-env-imports.pyand a paired pytest module (tests/unit/test_dev_env_imports.py, 2 new parametrized cases) that hard-fail (not skip) ifdjust.components.componentsor its.markdownsubmodule cannot import. Locks in the #1149 fix where missingmarkdown/nh3caused opaque pytest collection failures. Script is standalone for now; a follow-up PR can wire it into pre-commit / Makefile.
- #1160: rewrite
- CSP-strict defaults canonicalized for new client-side framework code (closes #1175) — adds explicit guidance in
CLAUDE.md,docs/PULL_REQUEST_CHECKLIST.md, anddocs/guides/security.mdthat any new framework feature emitting HTML must default to: external static JS modules (no inline<script>blocks), no inline event handlers (noonclick=/onchange=/oninput=), auto-bind via marker class + delegated listener ondocument/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, existing39-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 shippedrow_click_event/row_urltemplate-tag args, mixin defaults, and structural wiring). What's added:- Accessibility: every row-clickable
<tr>now rendersrole="button",tabindex="0", andcursor: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 === trso 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-phasestopImmediatePropagation, so the row-level action never fires for those clicks. This is the integration point withselectable=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 underscript-src 'self'out of the box. - Defense-in-depth:
data-hrefvalues are regex-validated against/^(https?:|\/|\.)/beforewindow.location.assign, so a hostilejavascript: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 intests/js/data_table_row_click.test.jscover: role + tabindex presence, marker class on/off, no-inline- onclick (CSP), checkbox cell composition, click navigation, nested<a>/<input>guard, Enter/Space activation,activeElementguard, javascript: URI rejection, dj-click composition (capture-phase stop), and bindRow idempotence. One pre-existing structural test inpython/tests/test_data_table_link_row_nav.pywas rewritten to assert the newdata-table-row-clickablemarker class instead of the removed inlineonclick.- Accessibility: every row-clickable
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 onlocalhost:80xx(or any shared domain) don't overwrite each other's theme preferences. Browsers scope cookies by domain only — not by port — so the fourdjust_theme*cookies bleed across projects without this. PR #1013 already shippedenable_client_override: Falseas a workaround, but that breaks sites with a user-facing theme switcher; this is the missing piece for those sites. Whencookie_namespace="djust_org"is set, the cookies becomedjust_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) readswindow.__djust_theme_cookie_prefixinjected bytheme_head.htmland writes only the namespaced name when set. When unset (default), the legacy unprefixed names are used — existing deployments unaffected. 8 new regression cases intests/unit/test_theming_cookie_namespace_1158.pycover namespaced precedence, unprefixed fallback, default back-compat, two-namespace isolation, all four cookies honour the namespace, and thetheme_head.html+theme.jswrite-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 onRustLiveViewgot a "no handler registered for tag: live_render" template error if they usedlazy=True, forcing a fallback to the slower Django engine to use streaming. The Rust handler delegates to the existing Python implementation indjust.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 onparent._lazy_thunks, same CSP nonce propagation, samesticky=True + lazy=Truecollision raise. The bridge required threading the raw Python sidecar (request,view) through to the custom-tag handler context:crates/djust_coreexposesContext::raw_py_objects()for read access, andcrates/djust_templates::registryaddscall_handler_with_py_sidecar(a backward-compatible variant ofcall_handler— existing handlers ignore the extra Python objects in their dict). 8 parity regression cases intests/unit/test_rust_live_render_lazy_1145.pycover lazy=True placeholder byte equivalence, lazy="visible" parity, thunk stash on the Rust path, CSP nonce parity, sticky+lazy collision, the inline-attributetemplate = "..."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-timeTemplateSyntaxErrorto a startup-time warning so the misuse surfaces duringmanage.py checkinstead 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 canreplaceWiththe stashed subtree;lazy=Truedefers 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_blockshelper from the v0.7.3 #1004 fix). Silenceable per-project viaDJUST_CONFIG = {"suppress_checks": ["A075"]}. 8 regression cases inTestA075StickyLazyCollisioncover 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 propagatesrequest.csp_nonce(the Django convention set bydjango-cspmiddleware) onto BOTH the<template id="djl-fill-X">element AND the inline<script>activator that callswindow.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 readsgetattr(request, 'csp_nonce', None)via the existingdjust.utils.get_csp_noncehelper — no additional configuration is required for any CSP middleware that follows the Django convention. Whenrequest.csp_nonceis absent or empty (the common case for sites without CSP middleware), nononceattribute is emitted — backward-compatible for non-CSP deployments. The placeholder<dj-lazy-slot>also carries the nonce so client-side code can read it viagetAttribute('nonce')if it ever needs to inject CSP-bound scripts under the same policy. 6 Python regression cases intests/unit/test_lazy_render_csp.py- 3 JS cases in
tests/js/lazy_fill_csp.test.jscover nonce propagation, backward compatibility (no nonce attr whencsp_nonceis absent / empty / missing), and HTML-escaping defense-in-depth for hostile-middleware substitutes.
- 3 JS cases in
Changed
- Dev-deps include
markdownandnh3(closes #1149) — both packages are runtime deps of the[components]extra (seepython/djust/components/components/markdown.py) and the components subpackage's__init__.pyeagerly imports them viafrom .markdown import Markdown. Tests that importdjust.components.components(directly or transitively) failed collection in clean checkouts that ran onlyuv syncwithout the[components]extra. The bisect agent in PR #1159 hit this on a fresh clone. Added both to[project.optional-dependencies.dev]so a singleuv sync --extra devbrings 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 distinguishesNone(cookie not in jar) from""(cookie set to empty), and only falls back in the former case. - (b)
cookie_namespacevalidated 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 raisesImproperlyConfiguredat 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.jssource-text patterns; newtests/js/theming_cookie_namespace_write.test.jsloads the file in JSDOM, setswindow.__djust_theme_cookie_prefix, firessetPack/setPreset/setLayout, and inspectsdocument.cookie. - (d) Legacy-cookie cleanup on first namespaced write. When
cookie_namespaceis set, every theming-cookie write intheme.jsnow also emitsMax-Age=0for 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 intests/js/theming_cookie_namespace_write.test.js(4 for sub-item (c), 3 for sub-item (d)).- (a) Empty namespaced cookie no longer falls back to legacy.
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.pypreviously used per-classsetup_registryfixtures that re-registered the Python built-in handlers on teardown but did NOT clear the global RustTAG_HANDLERSregistry first. Transient handlers from the file (notablyBrokenHandlerregistered for thebrokentag intest_handler_exception_returns_error) leaked into subsequent test files.test_assign_tag.pyrunning after this file would seehandler_exists("broken")== True; the parser dispatcheshandler_existsbeforeassign_handler_existsso{% broken %}was routed to the leaked CustomTag handler andtest_non_dict_return_is_empty_mergefailed 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 fromdjust.template_tags._registered_handlers. The file is now self-contained. - Sidecar parity: PR #1166's
call_handler_with_py_sidecaronly fired forNode::CustomTag. Block tags (Node::BlockCustomTag) and assign tags (Node::AssignTag) didn't receive therequest/viewsidecar, so a custom block or assign handler couldn't reach the parent view. Addedcall_block_handler_with_py_sidecarandcall_assign_handler_with_py_sidecarmirroring the PR #1166 pattern; the existing variants are kept as back-compat shims that delegate withNone. All five renderer call sites (1× block, 4× assign — single-node, sibling-aware, collecting, and partial-render paths) forwardcontext.raw_py_objects().
New cases in
TestBlockTagSidecarandTestAssignTagSidecar(tests/unit/test_tag_sidecar_parity_1167.py, 6 Python cases) cover sidecar receipt ofrequestandviewper node type plus a back-compat regression per node type confirming legacy handlers that ignore the sidecar continue to work unchanged.- Test isolation:
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:
- Hot-path Mutex perf:
is_custom_filter_safeandapply_custom_filtershort-circuit on a newANY_CUSTOM_FILTERS_REGISTEREDAtomicBoolso projects with no custom filters pay only an atomic load on every variable expansion'sfilter_specs.iter().any(...)loop, never a Mutex acquire. Acquire/Release ordering pairs the load with the store inregister_custom_filter. - Hardcoded
autoescape=Trueplumbing (correction, #1180):apply_custom_filternow accepts anautoescape: boolparameter that's set as a kwarg on the Python callable when the filter declaresneeds_autoescape=True. The earlier wording here was inaccurate — onlyapply_custom_filterwas widened; the upstream chain (apply_filter_fullinfilters.rsand the renderer's three call sites atrenderer.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. - Unknown-filter test tightened: assert
RuntimeErrortype AND the canonical"Unknown filter:"message shape, not justpytest.raises(Exception)+ substring on filter name only. - 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. - Fixture isolation comment: the
scope="module"autouse fixture intests/unit/test_rust_custom_filters_1121.pynow carries an explicit comment that this file is not safe to run in parallel with other Rust-filter-registry-touching tests. - Silent async filter handling: an
async defcustom filter previously stringified the unawaited coroutine ("<coroutine object ...>") into the rendered HTML with a "coroutine was never awaited" RuntimeWarning at GC. Now usesinspect.iscoroutineto detect and reject with a clear, actionable error andcoro.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) andautoescapekwarg flow (sub-item 2). Two new Rust unit tests infilter_registry::testscover theAtomicBoolshort-circuit pre-registration.- Hot-path Mutex perf:
replay_eventvalidates 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/privateevent_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 callsdjust.decorators.is_event_handler(handler)after attribute resolution, mirroring the dispatcher's own acceptance criteria (seewebsocket.py~ line 4389 server_push handler validation). Unregistered methods log a warning and returnNoneinstead of invoking. 3 regression cases inTestReplayHandlerValidationcover 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.filterin theirtemplatetags/modules saw them work in the Python render path but fail under the RustRustLiveViewrender path withRuntimeError: 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.rsholds a process-wideMutex<HashMap<String, FilterEntry>>of project filter callables + per-filter metadata (is_safe,needs_autoescape). - The renderer's filter loop forwards an
arg_was_quotedhint 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_safeandfilter.needs_autoescapefrom the Django filter object are honoured:is_safe=Truefilters skip auto-escape;needs_autoescape=Truefilters receiveautoescape=Trueas a kwarg. python/djust/template_filters.pywalkstemplate.engines['django'].engine.template_librariesat 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 inTestRustCustomFilterscover the lookup shape from the issue body,is_safe,needs_autoescape, quoted vs context- resolved args, plain-text auto-escape, and the fullRustLiveViewrender path.
- New
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/ChunkEmitterasync-render path and after PR #998 added theblock_watchdogtest 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 duringcommunicator.disconnect()because Channels' consumer dispatch invokesaclose_old_connections(), which iterates Django's connection cache and callsclose_if_unusable_or_obsolete()→get_autocommit()→ensure_connection(). SQLite ignoresclose()for in-memory DBs (data-loss prevention), so a prior django_db-marked test leaves the connection wrapper with.connection != Nonein the thread-local; pytest-django's blocker then fires inside the consumer's cleanup. Marked the affected tests@pytest.mark.django_dbso they participate in pytest-django's connection management. sys.modules["djust.checks"]rebind: thetest_dev_server_watchdog_missing.py::test_check_hot_view_replacement_survives_without_watchdogtest deleteddjust.checksfromsys.modulesand re-imported, creating a new module object whiletest_static_security_checks.pyhad already donefrom djust.checks import check_configurationat collection time. Subsequentmock.patch("djust.checks._has_multiple_permission_groups", ...)targeted the new module while the oldcheck_configurationkept resolving names against the old module's__dict__— so the patch silently no-op'd andtest_a020_fires_with_multiple_groupsfailed. Moved snapshot/restore ofdjust.checksanddjust.dev_serverinto theblock_watchdogfixture'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.
- In-memory SQLite + Channels disconnect: 5 tests (