Added
- Demo dogfood + playwright regression guard for the v1.0.2 navigation arc (#1742). The entire v1.0.2 nav arc (#1733 zero-wiring route map, #1737 SSR→hydration flash parity, #1738
DjustHooks/dj-hook) was driven by a downstream consumer becauseexamples/demo_projectdidn't exercise these paths end-to-end. Adds two plaindjust.LiveViewpages (NavDemoPageAView/NavDemoPageBViewat/demos/nav-a/and/demos/nav-b/) linked bydj-navigate— confirming the #1733 dogfood that SPA cross-view nav needs NOlive_session(),get_route_map_script(), or context-processor wiring (the route map auto-derives from the URLconf and auto-emits via{% djust_client_config %}already in the demo base<head>). Page A and Page B each carry aDemoWidgetdj-hook(canvas,dj-update="ignore") registered once in the persistent shell per the #1738 pattern. Newtests/playwright/test_nav_hooks.py(wired into theplaywright-testsCI job) asserts: awindowsentinel survives navigation andlocation.pathnamechanges (#1733 SPA nav, not a full reload); aMutationObserveron the[dj-view]root records zero direct-child remove/re-add during first-load hydration (#1737 no-flash); and the hook'smounted()marker is set on initial load AND a freshmounted()fires on the SPA patch-inserted Page B widget (#1738 hooks-survive-nav). A future regression in any of the three paths now red-bars CI internally. - Zero-wiring
dj-navigate— the client route map is now auto-derived from the URLconf and auto-emitted (#1733, ADR-021 Stage 1).dj-navigatepreviously SPA-navigated only if the developer manually wiredlive_session()and emittedget_route_map_script(); with no route map it silently full-reloaded. Nowdjust.routing.build_route_map_from_urlconf()walks the Django URLconf (descendinginclude()resolvers) and collects every route whose callback resolves to aLiveViewsubclass — handling bothcallback.view_classandlogin_required-wrappedcallback.__wrapped__.view_class, converting<int:id>→:id, and applying theFORCE_SCRIPT_NAMEsub-path prefix. The derived map is auto-emitted by{% djust_client_config %}(already in every scaffolded base<head>), with CSP-nonce support and empty-safe behavior (no<script>when an app has no LiveViews). Nolive_session()required. New system checkdjust.T016warns whendj-navigateappears in templates but the derived route map is empty (suppressible viaDJUST_CONFIG['suppress_checks']).
Changed
{% djust_client_config %}now takes the template context (#1733). The tag becametakes_context=Trueso it can readrequest.csp_noncefor the auto-emitted route-map<script>. The Rust-engine handler delegates to the same shared helper, so dual-engine output stays byte-identical. Existing templates need no change.get_route_map_script()now merges the URLconf-derived route map withlive_session()entries (#1733).Behavior change: the emittedwindow.djust._routeMapnow includes auto-derived LiveView routes in addition to anylive_session()registrations (idempotent union). Apps that calledget_route_map_script()with nolive_session()and expected an empty map now get the URLconf-derived routes — which is the intended zero-wiring behavior. The phantom{% djust_route_map %}reference in the docstring (a tag that never existed) was removed.live_session()remains valid for WebSocket session grouping.- eslint warnings driven to 0 and a
--max-warnings 0ceiling re-added (#1719, follow-up to #1717).#1717 changed the eslint policy to gate on errors and tolerate warnings, which left ~33 project-wide warnings inpython/djust/static/djust/*.jswith no ceiling, so the count could silently grow. All 33 are now resolved: 1prefer-constand 1no-var(single-assignment globals), 9no-unused-vars(8 unused caught errors → barecatch {}, 1 unused arg →_params), and 22security/detect-object-injection— each verified a false positive (Object.keys()own-prop iteration,isSafeKey()-guarded keys, validated numeric index, fixed-literal/allowlist key, or developer-supplied component/cache-key name) and given a targeted// eslint-disable-next-linewith a per-site justification (no blanket config disable; no site was a real injection risk). The--max-warnings 0ceiling is re-added to BOTH gate paths — the.pre-commit-config.yamleslint hook and thepackage.jsonlintscript (used by the Pre-Release Security Audit workflow) — so the warning count can only go down. Nosrc/module changed, so theclient.js/debug-panel.jsbundles rebuild byte-identically; all 1665 JS tests pass. - CI promotes the demo
djust_checkdogfood to a BLOCKINGdemo-checksjob (#1713, CI infra — completes #1708, applies CLAUDE.md rc4 retro finding #3). The dogfood added in #1708 ran as acontinue-on-errorstep inside the non-blockingplaywright-testsjob, so a re-introduced dead@click/ legacy attribute (the #1683 bug class) could NOT red-bar a PR. Now that the step has shipped green on the runner (the budgeted ≥1 runner-only iteration), it is extracted into a dedicateddemo-checksjob WITHOUTcontinue-on-errorand wired into thetest-summaryaggregate gate (added toneeds:and the blocking success condition alongside rust/python/js/security-tests). The dogfood step is removed fromplaywright-tests, which stays non-blocking and decoupled from the demo check (its ownmigratestep is retained for the dev server it starts). The wrapperscripts/ci_djust_check_demo.pyis refactored to extract an importableevaluate(parsed_json)decision function (CLI behavior ofmain()unchanged), and a new unit test (tests/test_ci_djust_check_demo.py) feeds SYNTHETICdjust_check --jsonpayloads through it to exercise BOTH gate arms end-to-end — the error-severity arm (never hit by the live 0-error demo, the Stage-11 note) and the deprecated-attrT001/T014/T015ID-set arm — the #252 empirical canary, with a clean-payload tautology guard (#254 gate-off verified: neutering the blocking arm makes all four blocking cases fail). No framework behavior change — CI config + test only.
Performance
theme_contextnow request-scope memoizes its four tag-body renders (#1727, follow-up to #1722's Stage 11 PERF-1).#1722 madetheme_contextrun on every WebSocket event via_apply_context_processors(rust_bridge.py). Each call re-rendered four uncached tag bodies (theme_head,theme_panel,theme_mode_toggle,theme_preset_selector) —_render_theme_outputs(CSS/switcher) was already@lru_cache'd, these four were not, so theming users paid four uncached tag renders per WS event. The four_safe_renderoutputs are now memoized on the request object, keyed on the resolved theme-state tuple (theme, preset, pack, mode, resolved_mode, layout, presets_key— the same shape_render_theme_outputskeys on). When theme state is unchanged across events the cache serves the strings; a live theme/mode/preset switch changes the key and recomputes, so dynamic switching is preserved (NOT first-sync-gated — CLAUDE.md v1.0.2 canon: per-event work feeding change-detection must be memoized, not skipped). Scope is request-level (not a cross-request module cache) by design: none of the four outputs currently embed per-request data (no CSP nonce;cookie_prefix_jsderives from thecookie_namespaceconfig, not the request), but request-scoped caching cannot leak a future per-request value across requests; on the WS pathrequestis a long-lived instance attr set once inhandle_connect, so the cache spans all events of a connection and invalidates on a theme switch. New regression tests intest_theme_context_memoize_1727.py(call-count fail-before/pass-after, theme-switch recompute, request isolation; #254 gate-off verified).
Security
- Bump starlette 1.0.0 → 1.2.1 (CVE-2026-48710 host-header validation; transitive via mcp).
Fixed
- Fixed deterministic cross-test pollution in
test_checks.py(#1741). Two checks tests (TestC003DaphneOrdering::test_c003_daphne_missing_infoandTestSuppressChecks::test_no_suppress_by_default) failed deterministically under the cross-dir orderpytest python/djust/tests/ python/tests/test_checks.py(and intermittently in CI shards) while passing in isolation. Root cause: theblock_watchdogfixture intest_dev_server_watchdog_missing.pyre-importsdjust.checksto exercise the no-watchdog import path (#994); a re-import rebinds bothsys.modules["djust.checks"]AND the parent-package attributedjust.checks, but the fixture restored onlysys.modules. The two then pointed at different module objects, so a downstreammonkeypatch.setattr(checks, "_has_asgi_server", ...)patched one copy while the function under test resolved against the other — the patch silently no-op'd and C003 failed (0 == 1). The fixture now snapshots and restores the parent-package attribute alongsidesys.modules. Test-only change; no runtime behavior change. - Initial SSR render now matches the first WebSocket frame — eliminates the first-hydration flash (#1737, completes #1724). The initial HTTP-GET render skipped the comment/whitespace normalization that
render_with_diff()applies, so the server-rendered dj-root kept HTML comment nodes and as-authored inter-element whitespace while the first WS frame had them stripped. The structural mismatch made the client's first-hydrationmorphChildrenrebuild the whole subtree (visible re-render / flash), even with #1724's client-side whitespace-only-text-node skip in place.render_full_template()now (a) falls back to matching thedj-viewroot when no literaldj-rootattribute is present (the auto-inferred-dj-root case) so the normalized render replaces the shell root, and (b) applies_strip_comments_and_whitespace()to the rendered dj-root, mirroring the extra whitespace pass the Rustrender_with_diff()performs._strip_comments_and_whitespace()now also collapses whitespace adjacent to<pre>/<code>/<textarea>boundaries for byte-parity with the Rust pass. The SSR dj-root is now byte-equivalent to the first WS frame (modulo thedj-idattrs the client stamps onto the prerender DOM per #1610), so the firstmorphChildrenis a no-op.<pre>/<code>/<textarea>internal whitespace anddj-ifboundary markers are preserved. - The cross-IIFE bare-reference static guard (
scripts/check-cross-iife-refs.mjs, #1706) now also catches bare references between two TOP-LEVEL bundle modules (22-51), not only guard-block→top-level references (#1716). Each top-level module wraps its body in its own inner IIFE, so aglobalThis.djust-published function declared inside module A's IIFE is not visible as a bare name in module B — the sameReferenceError-under-terser class as #1676/#1688, and 10 of 58 published functions were gap-exposed. The narrow scope test (decl.inGuard && !refInGuard) is generalized to a lexical scope-barrier model: each published declaration gets a barrier span (its innermost enclosing function/IIFE body, else the double-load-guardelse {}block, else null for program scope), and a bare reference is flagged iff it falls outside that span. Program-scope declarations (e.g.maybeDeferRemovalin42-dj-remove.js, a true global visible everywhere even minified) have no barrier and are never flagged — the load-bearing false-positive control. Empirical canary (#252): a synthetic top-level publisher + bare cross-module ref exits 0 under the old code and 1 under the new code; the real tree stays clean (no new false positives), and a gate-off self-test (#254) confirms reverting the generalization makes exactly the new canary fail. New regression cases intests/js/check-cross-iife-refs-1706.test.js. - The documented
{% theme_X %}template tags ({% theme_panel %},{% theme_head %},{% theme_switcher %},{% theme_mode_toggle %},{% theme_preset_selector %}, and the other user-facing theme tags) now work in djust's Rust template engine — the engine that renders LiveView templates (#1721). Previously the Rust engine raisedRuntimeError: Template error: Unsupported template tag '{% theme_panel %}'(a 500) for these tags even though the theming guide documents them, while only the{{ theme_panel }}context-string form (#1435) rendered — so docs and engine disagreed (cf. #1452 on{{ theme_head }}vs{% theme_head %}). The fix registers a thinTagHandlerbridge for each documented theme tag viadjust._rust.register_tag_handlerinDjustThemingConfig.ready(); each handler delegates to the sametheme_tags.py@register.simple_tagbody the{{ }}form uses, so the two forms produce equivalent output for default args and the customization-with-args form ({% theme_panel show_packs=False %}) works. Python-only registration (no Rust rebuild); degrades gracefully when the Rust extension is absent, and Django-engine templates are unchanged. Performance note: like every Rust custom-tag handler, the{% theme_X %}form crosses the PyO3 boundary and runs a Python sidecar per invocation; the{{ theme_X }}context-string form pre-renders once per request and is cheaper when the same tag appears multiple times on a page. New regression tests intest_theme_tags_rust_engine_1721.py(Rust-engine fail-before/pass-after,{{ }}parity, kwargs form; gate-off verified). - Context-processor variables (e.g. djust theming's
{{ theme_panel }}/{{ theme_head }}) now render inside the LiveView's dj-root template and its nested{% include %}partials, not just at the page top level (#1722, completes #233). Previously context processors were applied only to the outer page shell inrender_full_template; the dj-root render path (render()/render_with_diff()via_sync_state_to_rust, used on the initial GET and every WebSocket update) never applied them, so a context-processor var used in the dj-root template or an include reached from it resolved to empty while plain view attributes worked._sync_state_to_rustnow applies_apply_context_processors(no-op when there is no request; view context still wins on key collisions). - Add inline CodeQL suppression comments for false-positive alerts in source JS:
js/remote-property-injectionin03-websocket.js(server-sent view name) anddebug/07-tab-state.js(null-prototype clone with UNSAFE_KEYS filter);js/xssin live_redirect path (target validated to same-origin path). Rebuildsclient.jsanddebug-panel.jsto pick up the suppressions. - Fix
UploadWriter.write_chunkbase class signature to accept optionalchunk_index: int = 0— resolves CodeQLpy/inheritance/incorrect-overridden-signature(#2190); callers already guard via_supports_chunk_indexintrospection. - SSR→hydration no longer replaces the
dj-viewroot's top-level children wholesale on the first WebSocket hydration (#1724). Root cause was whitespace text-node misalignment inmorphChildren: real SSR HTML carries inter-element whitespace text nodes between sibling elements, and when the positional existing node landed on such a whitespace node, every element-matching strategy was skipped and the code fell through to clone+insert + remove-unmatched — a wholesale teardown.morphChildrennow skips insignificant whitespace-only text nodes when aligning a desired element (or dj-if boundary comment) so the existing element is morphed in place. This eliminates the full visible re-render on every navigation and preserves client-side widget state mounted on those nodes (e.g. a Chart.js<canvas>no longer goes blank). Significant whitespace inside<pre>/<code>/<textarea>, dj-if comment markers, and legitimate keyed reorder/replace are unaffected.
Documentation
- Integrating third-party JS libraries (Chart.js, maps, editors) via client hooks (#1738). Extended the Client-Side JavaScript Hooks guide with a section that leads with the inline-
<script>trap — a<script>next to the element inside the reactive (dj-view) root runs on a full page reload but is silently blank afterdj-navigateSPA navigation (morphed-in content's scripts don't execute; no error), the confusing "reload works, navigation doesn't" signature. Documents the canonical pattern: register the hook once in the persistent shell (window.DjustHooks.X = { mounted, updated, destroyed }), opt in via<canvas dj-hook="X" dj-update="ignore">, init the library inmounted()(fires on hydration AND SPA patch-insert), and dispose indestroyed(); explains whydj-update="ignore"keeps the VDOM from fighting the library's own DOM mutations. Cross-linked from the navigation guide'sdj-navigatesection as the answer to "my Chart.js chart is blank after navigation."