Skip to content

v1.1.0rc7

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 10 Jul 15:44
· 372 commits to main since this release

Added

  • Django {% regroup %} support in the Rust template engine (#2023). {% regroup <expr> by <attr> as <var> %} now regroups a flat sequence into [{"grouper": key, "list": [...]}, ...], matching Django's RegroupNode consecutive-grouping semantics (input order preserved, never pre-sorted). Implemented as a built-in assign tag handler (RegroupTagHandler) plus a JSON-aware resolve_tag_arg in renderer.rs that brings the assign-tag arg path to parity with CustomTag (structured list/object args are JSON-encoded instead of collapsing to the opaque [List]/[Object] placeholder). <attr> supports dotted paths (author.team). Known limitations vs. Django (documented on RegroupTagHandler): filter expressions on the source (cities|dictsort:"country") are unsupported. (A context key whose name matched the <attr> token could originally shadow the per-item lookup; that footgun's durable fix — passing the keyword/name operands unresolved — landed in #2041, see below.) Regression coverage in test_regroup_tag.py drives the real Rust engine via both render_template and RustLiveView.render_with_diff with a Django-parity anchor.
  • Debug panel is now dockable (bottom/left/right) and resizable. The dev
    toolbar was a hardcoded full-width 400px bottom dock, which permanently
    covered bottom-anchored app UI — a chat input, sticky footer, or bottom
    nav — while open. New dock buttons in the panel header switch between
    bottom (default), left, and right edge docking (side docks are full-height
    panels that leave the bottom of the page visible); a drag handle on the
    panel's inner edge resizes it (clamped to 160px–90vh height /
    320px–90vw width); dock position and size persist per view via the
    existing localStorage UI-state path, with validation on load. The
    floating toggle button moves out from under the open panel and returns to
    its corner on close. DjustDebugPanel's previously-dead config.position
    now seeds the initial dock, and setDock('bottom'|'left'|'right') is
    available at runtime. New cases in tests/js/debug_panel_dock.test.js.

Fixed

  • {% regroup <src> by <attr> as <var> %} no longer groups by the wrong attribute when a context key is named after the <attr> token (#2041). The Rust engine resolved every assign-tag arg against the render context before calling the handler, so the <attr> operand was resolved too: a top-level context variable named like the attribute (country, type, category, … — djust auto-exposes public view attrs to the template context) shadowed the per-item lookup. <attr> arrived as that key's value instead of the literal attribute name, and the grouping was silently wrong (every row collapsing into one bogus group). Django never resolves <attr> against the outer context. The #2023 mitigation only logger.warning-ed when the resolved attr wasn't a bare identifier — it missed the shadow-to-an-identifier-value case (country → "usa"). Durable fix: AssignTagHandler now carries a RESOLVE_ARG_POSITIONS class attribute (set[int] | None; None = resolve all args, the unchanged default for any assign tag that doesn't opt in). register_assign_tag_handler reads it once at registration, and one shared resolve_assign_tag_args helper — routed through all four AssignTag dispatch sites in renderer.rs (the #1646 parallel-path cure) — resolves ONLY the declared positions and passes the rest as literal tokens. RegroupTagHandler declares {0}, so only the <expr> source is resolved (still JSON-encoded as before); by / <attr> / as / <var> stay literal, making the shadow impossible while dotted by author.team continues to resolve as a per-item path. Removes the now-obsolete logger.warning + _IDENTIFIER_RE shadow heuristic from regroup.py. Regression coverage in test_regroup_tag.py: two end-to-end shadow reproducers (a plain country key and a dotted author.team, both via render_template) plus a pin on RegroupTagHandler.RESOLVE_ARG_POSITIONS == {0}; gate-off verified (forcing the mask off turns both reproducers red).

  • {% ... %} block custom tags no longer collapse a list/object argument to the opaque [List]/[Object] placeholder (#2042). The Rust template engine had THREE tag-dispatch arg-resolution branches, but only CustomTag and AssignTag (post-#2023) JSON-encoded structured (list/object) args; BlockCustomTag used a hand-copied inline resolver that skipped the JSON encoding, so a block handler received "[List]"/"[Object]" and lost the payload. Extracted ONE shared module-level value_to_arg_string (previously duplicated inline in resolve_tag_arg and the CustomTag arm) and routed all three branches through it, folding BlockCustomTag onto the same resolve_tag_arg helper AssignTag already uses — the #1646 parallel-path cure (retire the collapse class, not fix 2-of-3). CustomTag/AssignTag behavior is byte-for-byte preserved (filter-aware get_value for CustomTag, scalars unchanged everywhere); the only behavior change is BlockCustomTag now JSON-encodes list/object args. New end-to-end Rust test in crates/djust_templates/tests/test_block_custom_tag_arg_json_2042.rs (real BlockCustomTag dispatch through a registered Python block handler; single #[test] because parallel Python::attach across cargo's default harness deadlocks) plus 5 Python-free unit tests in renderer.rs pinning the shared value_to_arg_string/resolve_tag_arg contract; gate-off verified (reverting only the BlockCustomTag routing turns the integration test red with got "[List]").

  • A Django Model/QuerySet stored on PUBLIC LiveView state (enable_state_snapshot) no longer silently comes back as a plain dict after a back-navigation restore — it now fails loud and early instead. _capture_snapshot_state (the client-signed state_snapshot_signed mount-emission path in runtime.py) reuses djust.serialization.DjangoJSONEncoder, which — unlike the plain encoder — does know how to serialize a Model (_serialize_model_safely). So self.user = request.user in mount() silently succeeded the JSON round-trip and shipped as a lossy, disconnected field-value dict; on the next back-navigation restore, self.user came back that dict, not a User, and a handler calling a model method on it (self.user.get_full_name()) broke with a confusing, origin-unclear AttributeError far from the actual mistake. Sibling of #1994 (the same shape for PRIVATE state, fixed by re-hydrating a DB ref) — but public, client-signed state must not attempt automatic re-hydration by pk (that's exactly the mass-assignment shape state_snapshot_signed's HMAC signing exists to prevent), so the fix instead rejects early: a new LiveView._reject_orm_value_in_state_persistence guard, applied only when _capture_snapshot_state(strict=True) (the real runtime.py persistence caller), raises NonPersistableStateError (a TypeError subclass) in DEBUG with actionable guidance (store the pk, refetch in the handler — e.g. self.user_id = user.pk) or logs a warning and skips the attribute in production. The dedicated exception class exists because the only strict=True caller wraps snapshot emission in a broad except Exception (the #1788 "snapshot emission must never break mount" posture): runtime.py now re-raises this one deliberate rejection past that wrapper, so the DEBUG failure is loud on the REAL mount path too — not only when _capture_snapshot_state is called directly (review finding). Deliberately scoped to strict-mode ONLY so the two other callers of the same method are unaffected: the rendering JIT pipeline (_is_serializable/get_state(), which intentionally lets Model/QuerySet through for template rendering) and the dev-only time-travel debug capture (time_travel.py, which already accepts a lossy snapshot by design for state_before/state_after). 8 new tests in test_state_snapshot_orm_early_validation.py (DEBUG raise, production warn+skip, non-strict callers unaffected, rendering pipeline unaffected, and two real-path dispatch_mount cases: DEBUG propagates out of the runtime wrapper / production mount survives with the ORM key skipped from the signed blob). live_view.pyi (the ADR-023 strict-island stub runtime.py type-checks against) also declares NonPersistableStateError now — mypy resolves module attributes against the .pyi when both it and the .py exist, so the new class was invisible to the strict-island check until added there too (CI catch, not a runtime bug).

  • {% djust_markdown %} (and any custom tag) corrupted a loop/include-scoped dict-field value into a Python-tuple-repr (#2037). The Rust custom-tag dispatch pre-resolves a bare-name argument (block.text) to its value string before handing it to the Python handler; TagHandler._resolve_arg then re-interpreted that already-resolved value as a template token — any value containing = was tuple-split, so the markdown source rendered as a literal ('...', '...') repr (observed in a production chat app on a per-{% for %}/{% include ... with %}-scoped block.text whose text contained =); a dotted value could likewise be re-resolved against the context. _resolve_arg now token-guards its kwarg-split and dotted-lookup heuristics, so a non-token (Rust-resolved) value is returned verbatim; the markdown handler additionally falls back to the raw source string if a positional source ever resolves to a tuple. Root cause reproduced deterministically at the unit, handler, and real Rust-render levels — the report's streaming/loop-cache hypothesis was a red herring. New cases in TestResolveArgNoDoubleResolution, TestMarkdownHandlerResolvedSource, and TestRealPathLoopScopedMarkdown.

  • dj-virtual overlapping/garbled content after SPA navigation (#2033).
    SPA-style navigation (same page, no reload) can reuse the SAME physical
    [dj-virtual] container node across a view/data-source change instead of
    remounting. Client virtualization state lives in a WeakMap keyed on the
    container node's identity, and the never-removed shell/spacer survive the
    server morph, so nothing tore the old virtualization down: viewing a
    virtualized thread then navigating to a small non-virtualized thread left a
    leftover row from the previous thread rendered at the shell's stale
    translateY, overlapping the new view's real rows (a full page reload fixed
    it). Three client gaps closed in 29-virtual-list.js: structureIntact()
    now fails closed on identity change (dj-virtual removed / value changed /
    dj-id changed) so a repurposed container is never treated as still
    virtualized; a new reapStaleVirtualLists() discovers tracked containers via
    the never-removed data-dj-virtual-shell marker (the WeakMap isn't
    iterable) and tears down any whose identity changed — WITHOUT restoring the
    old item pool (the morph already authored the new content), re-virtualizing
    fresh if the container still carries dj-virtual; and absorbLooseChildren()
    no longer accumulates loose children across an identity change, so the
    previous thread's rows and the new thread's rows can't merge into one pool.
    The normal same-thread self-heal (#1988/#1989 absorb path) is unchanged — the
    fix is scoped to the identity-CHANGE case. Regression coverage: 3 cases in
    tests/js/dj-virtual-teardown-2033.test.js (attr-loss teardown, dj-id-change
    re-virtualize-fresh with no cross-source merge, and a same-thread absorb
    regression guard), with a gate-off sentinel that turns the two
    identity-change cases RED when the teardown is disabled.

Changed

  • python/djust/tests/ is now a blocking CI gate + covered by the pre-push hook (#2034). The #2032 soak step (continue-on-error) ran green on the main push-CI run at 72d78601, satisfying the #1534 green-on-runner-first precondition, so it is promoted to a blocking step in the python-tests job — a failure now fails python-tests, which is in the test-summary AND-condition (#1713), so it gates the merge. The pre-push pytest hook gains python/djust/tests/ too, matching CI (the explicit tests/ python/tests/ paths override pyproject's testpaths, which is how this ~4000-test dir was historically absent from both surfaces). Empirically validated: pytest python/djust/tests/ -n auto → 4066 passed, 3 skipped. No runtime/behavior change.
  • CI now covers python/djust/tests/, and the stale setattr-chokepoint security guard is green again (#2032). The gating Python job ran pytest tests/ python/tests/ — explicit paths that override pyproject's testpaths — so a large suite (V008 checks, mount-chokepoint structural pins, restore tests) ran in neither CI nor the pre-push hook. As a result the TestSetattrChokepoint CWE-915 mass-assignment guard had been RED on main undetected (a sanctioned DynamicLiveView function-view-decorator setattr site drifted off the whitelist's pinned line numbers). The whitelist is re-verified and corrected, and python/djust/tests/ is added to CI as a non-gating soak step (continue-on-error, per the #1534 green-on-runner-first rule); promotion to a blocking gate + pre-push coverage is tracked in #2034. No runtime/behavior change.

Security

  • Cookie-derived theme values are sanitized before debug logging (CWE-117 log injection; CodeQL py/log-injection #2563#2569). ThemeManager.get_state logged the four client-set theming cookies (djust_theme / _preset / _pack / _layout) to two logger.debug calls unsanitized. The calls already used %s parameterization (not a format-string bug), but a cookie value carrying a CR/LF could still forge log lines or poison SIEM parsers — the values are attacker-controlled and reached the log verbatim. Each cookie-derived value is now routed through sanitize_for_log (the CodeQL-recognized barrier in djust._log_utils, which strips CR/LF/control chars), matching the pattern already used across runtime.py, sse.py, and the theming gallery. Behavioral regression test in python/djust/tests/test_theming_log_injection.py drives get_state with CRLF-laden cookies and asserts no rendered log record carries a raw newline (gate-off verified: reverting the sanitization makes the forged [CRITICAL] line reappear). No runtime/behavior change to theme resolution.

What's Changed

  • fix(ci): cover python/djust/tests + fix stale setattr-chokepoint whitelist (#2032) by @johnrtipton in #2035
  • fix(vdom): attribute/identity-driven teardown for dj-virtual on SPA nav (#2033) by @johnrtipton in #2036
  • ci(python): promote python/djust/tests to a blocking gate + pre-push coverage (#2034) by @johnrtipton in #2039
  • fix(template-tags): stop double-resolving Rust-resolved custom-tag arg values (#2037) by @johnrtipton in #2038
  • docs: replace nonexistent djust_tags/djust_scripts with live_tags + djust_client_config by @szto in #2021
  • feat(templates): support Django {% regroup %} via built-in assign-tag handler by @szto in #2023
  • fix(live): early, actionable error when ORM objects are stored in LiveView state by @szto in #2022
  • feat(debug-panel): dockable dev toolbar — bottom/left/right dock + drag resize by @szto in #2040
  • refactor(templates): shared value_to_arg_string across all 3 tag-dispatch paths (#2042) by @johnrtipton in #2043
  • fix(templates): pass assign-tag operands unresolved — durable regroup shadow fix (#2041) by @johnrtipton in #2044
  • security(theming): sanitize cookie-derived values before debug logging (CodeQL #2563#2569) by @johnrtipton in #2050
  • deps: bump uuid from 1.23.3 to 1.23.4 by @dependabot[bot] in #1972
  • deps: bump regex from 1.12.4 to 1.13.0 by @dependabot[bot] in #2045
  • deps: bump vitest from 4.1.9 to 4.1.10 by @dependabot[bot] in #2046
  • deps: bump @vitest/coverage-v8 from 4.1.9 to 4.1.10 by @dependabot[bot] in #2048
  • deps: bump eslint from 10.5.0 to 10.6.0 by @dependabot[bot] in #2024
  • deps: bump terser from 5.48.0 to 5.49.0 by @dependabot[bot] in #2047
  • fix(scaffold): read DJUST_SQLITE_PATH so djust new deploys on a read-only rootfs by @johnrtipton in #1983
  • ci: bump actions/cache from 5 to 6 by @dependabot[bot] in #1971

New Contributors

Full Changelog: v1.1.0rc6...v1.1.0rc7