v1.1.0rc7
Pre-releaseAdded
- 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'sRegroupNodeconsecutive-grouping semantics (input order preserved, never pre-sorted). Implemented as a built-in assign tag handler (RegroupTagHandler) plus a JSON-awareresolve_tag_arginrenderer.rsthat brings the assign-tag arg path to parity withCustomTag(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 onRegroupTagHandler): 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 intest_regroup_tag.pydrives the real Rust engine via bothrender_templateandRustLiveView.render_with_diffwith 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
existinglocalStorageUI-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-deadconfig.position
now seeds the initial dock, andsetDock('bottom'|'left'|'right')is
available at runtime. New cases intests/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 onlylogger.warning-ed when the resolved attr wasn't a bare identifier — it missed the shadow-to-an-identifier-value case (country → "usa"). Durable fix:AssignTagHandlernow carries aRESOLVE_ARG_POSITIONSclass attribute (set[int] | None;None= resolve all args, the unchanged default for any assign tag that doesn't opt in).register_assign_tag_handlerreads it once at registration, and one sharedresolve_assign_tag_argshelper — routed through all fourAssignTagdispatch sites inrenderer.rs(the #1646 parallel-path cure) — resolves ONLY the declared positions and passes the rest as literal tokens.RegroupTagHandlerdeclares{0}, so only the<expr>source is resolved (still JSON-encoded as before);by/<attr>/as/<var>stay literal, making the shadow impossible while dottedby author.teamcontinues to resolve as a per-item path. Removes the now-obsoletelogger.warning+_IDENTIFIER_REshadow heuristic fromregroup.py. Regression coverage intest_regroup_tag.py: two end-to-end shadow reproducers (a plaincountrykey and a dottedauthor.team, both viarender_template) plus a pin onRegroupTagHandler.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 onlyCustomTagandAssignTag(post-#2023) JSON-encoded structured (list/object) args;BlockCustomTagused 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-levelvalue_to_arg_string(previously duplicated inline inresolve_tag_argand theCustomTagarm) and routed all three branches through it, foldingBlockCustomTagonto the sameresolve_tag_arghelperAssignTagalready uses — the #1646 parallel-path cure (retire the collapse class, not fix 2-of-3).CustomTag/AssignTagbehavior is byte-for-byte preserved (filter-awareget_valueforCustomTag, scalars unchanged everywhere); the only behavior change isBlockCustomTagnow JSON-encodes list/object args. New end-to-end Rust test incrates/djust_templates/tests/test_block_custom_tag_arg_json_2042.rs(realBlockCustomTagdispatch through a registered Python block handler; single#[test]because parallelPython::attachacross cargo's default harness deadlocks) plus 5 Python-free unit tests inrenderer.rspinning the sharedvalue_to_arg_string/resolve_tag_argcontract; gate-off verified (reverting only theBlockCustomTagrouting turns the integration test red withgot "[List]"). -
A Django
Model/QuerySetstored on PUBLIC LiveView state (enable_state_snapshot) no longer silently comes back as a plaindictafter a back-navigation restore — it now fails loud and early instead._capture_snapshot_state(the client-signedstate_snapshot_signedmount-emission path inruntime.py) reusesdjust.serialization.DjangoJSONEncoder, which — unlike the plain encoder — does know how to serialize aModel(_serialize_model_safely). Soself.user = request.userinmount()silently succeeded the JSON round-trip and shipped as a lossy, disconnected field-value dict; on the next back-navigation restore,self.usercame back thatdict, not aUser, and a handler calling a model method on it (self.user.get_full_name()) broke with a confusing, origin-unclearAttributeErrorfar 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 shapestate_snapshot_signed's HMAC signing exists to prevent), so the fix instead rejects early: a newLiveView._reject_orm_value_in_state_persistenceguard, applied only when_capture_snapshot_state(strict=True)(the realruntime.pypersistence caller), raisesNonPersistableStateError(aTypeErrorsubclass) inDEBUGwith 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 onlystrict=Truecaller wraps snapshot emission in a broadexcept Exception(the #1788 "snapshot emission must never break mount" posture):runtime.pynow 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_stateis 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 forstate_before/state_after). 8 new tests intest_state_snapshot_orm_early_validation.py(DEBUG raise, production warn+skip, non-strict callers unaffected, rendering pipeline unaffected, and two real-pathdispatch_mountcases: 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 stubruntime.pytype-checks against) also declaresNonPersistableStateErrornow — mypy resolves module attributes against the.pyiwhen both it and the.pyexist, 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_argthen 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 %}-scopedblock.textwhose text contained=); a dotted value could likewise be re-resolved against the context._resolve_argnow 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 inTestResolveArgNoDoubleResolution,TestMarkdownHandlerResolvedSource, andTestRealPathLoopScopedMarkdown. -
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 aWeakMapkeyed 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 in29-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 newreapStaleVirtualLists()discovers tracked containers via
the never-removeddata-dj-virtual-shellmarker (theWeakMapisn'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 carriesdj-virtual; andabsorbLooseChildren()
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 themainpush-CI run at72d78601, satisfying the #1534 green-on-runner-first precondition, so it is promoted to a blocking step in thepython-testsjob — a failure now failspython-tests, which is in thetest-summaryAND-condition (#1713), so it gates the merge. The pre-pushpytesthook gainspython/djust/tests/too, matching CI (the explicittests/ python/tests/paths override pyproject'stestpaths, 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 ranpytest tests/ python/tests/— explicit paths that override pyproject'stestpaths— so a large suite (V008 checks, mount-chokepoint structural pins, restore tests) ran in neither CI nor the pre-push hook. As a result theTestSetattrChokepointCWE-915 mass-assignment guard had been RED onmainundetected (a sanctionedDynamicLiveViewfunction-view-decoratorsetattrsite drifted off the whitelist's pinned line numbers). The whitelist is re-verified and corrected, andpython/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_statelogged the four client-set theming cookies (djust_theme/_preset/_pack/_layout) to twologger.debugcalls unsanitized. The calls already used%sparameterization (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 throughsanitize_for_log(the CodeQL-recognized barrier indjust._log_utils, which strips CR/LF/control chars), matching the pattern already used acrossruntime.py,sse.py, and the theming gallery. Behavioral regression test inpython/djust/tests/test_theming_log_injection.pydrivesget_statewith 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 newdeploys 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