djust 1.0.2

StableSecurityReleased
Install
pip install djust==1.0.2

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 because examples/demo_project didn't exercise these paths end-to-end. Adds two plain djust.LiveView pages (NavDemoPageAView / NavDemoPageBView at /demos/nav-a/ and /demos/nav-b/) linked by dj-navigate — confirming the #1733 dogfood that SPA cross-view nav needs NO live_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 a DemoWidget dj-hook (canvas, dj-update="ignore") registered once in the persistent shell per the #1738 pattern. New tests/playwright/test_nav_hooks.py (wired into the playwright-tests CI job) asserts: a window sentinel survives navigation and location.pathname changes (#1733 SPA nav, not a full reload); a MutationObserver on the [dj-view] root records zero direct-child remove/re-add during first-load hydration (#1737 no-flash); and the hook's mounted() marker is set on initial load AND a fresh mounted() 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-navigate previously SPA-navigated only if the developer manually wired live_session() and emitted get_route_map_script(); with no route map it silently full-reloaded. Now djust.routing.build_route_map_from_urlconf() walks the Django URLconf (descending include() resolvers) and collects every route whose callback resolves to a LiveView subclass — handling both callback.view_class and login_required-wrapped callback.__wrapped__.view_class, converting <int:id>:id, and applying the FORCE_SCRIPT_NAME sub-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). No live_session() required. New system check djust.T016 warns when dj-navigate appears in templates but the derived route map is empty (suppressible via DJUST_CONFIG['suppress_checks']).

Changed

  • {% djust_client_config %} now takes the template context (#1733). The tag became takes_context=True so it can read request.csp_nonce for 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 with live_session() entries (#1733). Behavior change: the emitted window.djust._routeMap now includes auto-derived LiveView routes in addition to any live_session() registrations (idempotent union). Apps that called get_route_map_script() with no live_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 0 ceiling 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 in python/djust/static/djust/*.js with no ceiling, so the count could silently grow. All 33 are now resolved: 1 prefer-const and 1 no-var (single-assignment globals), 9 no-unused-vars (8 unused caught errors → bare catch {}, 1 unused arg → _params), and 22 security/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-line with a per-site justification (no blanket config disable; no site was a real injection risk). The --max-warnings 0 ceiling is re-added to BOTH gate paths — the .pre-commit-config.yaml eslint hook and the package.json lint script (used by the Pre-Release Security Audit workflow) — so the warning count can only go down. No src/ module changed, so the client.js/debug-panel.js bundles rebuild byte-identically; all 1665 JS tests pass.
  • CI promotes the demo djust_check dogfood to a BLOCKING demo-checks job (#1713, CI infra — completes #1708, applies CLAUDE.md rc4 retro finding #3). The dogfood added in #1708 ran as a continue-on-error step inside the non-blocking playwright-tests job, 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 dedicated demo-checks job WITHOUT continue-on-error and wired into the test-summary aggregate gate (added to needs: and the blocking success condition alongside rust/python/js/security-tests). The dogfood step is removed from playwright-tests, which stays non-blocking and decoupled from the demo check (its own migrate step is retained for the dev server it starts). The wrapper scripts/ci_djust_check_demo.py is refactored to extract an importable evaluate(parsed_json) decision function (CLI behavior of main() unchanged), and a new unit test (tests/test_ci_djust_check_demo.py) feeds SYNTHETIC djust_check --json payloads 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-attr T001/T014/T015 ID-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_context now request-scope memoizes its four tag-body renders (#1727, follow-up to #1722's Stage 11 PERF-1). #1722 made theme_context run 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_render outputs 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_outputs keys 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_js derives from the cookie_namespace config, not the request), but request-scoped caching cannot leak a future per-request value across requests; on the WS path request is a long-lived instance attr set once in handle_connect, so the cache spans all events of a connection and invalidates on a theme switch. New regression tests in test_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_info and TestSuppressChecks::test_no_suppress_by_default) failed deterministically under the cross-dir order pytest python/djust/tests/ python/tests/test_checks.py (and intermittently in CI shards) while passing in isolation. Root cause: the block_watchdog fixture in test_dev_server_watchdog_missing.py re-imports djust.checks to exercise the no-watchdog import path (#994); a re-import rebinds both sys.modules["djust.checks"] AND the parent-package attribute djust.checks, but the fixture restored only sys.modules. The two then pointed at different module objects, so a downstream monkeypatch.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 alongside sys.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-hydration morphChildren rebuild 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 the dj-view root when no literal dj-root attribute 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 Rust render_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 the dj-id attrs the client stamps onto the prerender DOM per #1610), so the first morphChildren is a no-op. <pre>/<code>/<textarea> internal whitespace and dj-if boundary 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 a globalThis.djust-published function declared inside module A's IIFE is not visible as a bare name in module B — the same ReferenceError-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-guard else {} block, else null for program scope), and a bare reference is flagged iff it falls outside that span. Program-scope declarations (e.g. maybeDeferRemoval in 42-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 in tests/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 raised RuntimeError: 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 thin TagHandler bridge for each documented theme tag via djust._rust.register_tag_handler in DjustThemingConfig.ready(); each handler delegates to the same theme_tags.py @register.simple_tag body 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 in test_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 in render_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_rust now 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-injection in 03-websocket.js (server-sent view name) and debug/07-tab-state.js (null-prototype clone with UNSAFE_KEYS filter); js/xss in live_redirect path (target validated to same-origin path). Rebuilds client.js and debug-panel.js to pick up the suppressions.
  • Fix UploadWriter.write_chunk base class signature to accept optional chunk_index: int = 0 — resolves CodeQL py/inheritance/incorrect-overridden-signature (#2190); callers already guard via _supports_chunk_index introspection.
  • SSR→hydration no longer replaces the dj-view root's top-level children wholesale on the first WebSocket hydration (#1724). Root cause was whitespace text-node misalignment in morphChildren: 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. morphChildren now 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 after dj-navigate SPA 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 in mounted() (fires on hydration AND SPA patch-insert), and dispose in destroyed(); explains why dj-update="ignore" keeps the VDOM from fighting the library's own DOM mutations. Cross-linked from the navigation guide's dj-navigate section as the answer to "my Chart.js chart is blank after navigation."

All releases · Atom feed