djust 1.1.0

StableSecurityReleased
Install
pip install djust==1.1.0

djust 1.1.0 was stabilized through 9 pre-releases, and most of its changes are recorded under them: 1.1.0rc1, 1.1.0rc2, 1.1.0rc3, 1.1.0rc4, 1.1.0rc5, 1.1.0rc6, 1.1.0rc7, 1.1.0rc8, 1.1.0rc9.

Added

  • LIVEVIEW_CONFIG['virtual_keyed_ops'] — a Python switch for the [dj-virtual] keyed splice ops (#2017, ADR-026 iteration 3). The flag had no Python surface at all: djust_vdom::diff::set_virtual_keyed_ops was reachable only from that crate's own tests, so "flip the flag" was never a one-line default change — there was nothing for a Django user to switch. It is now a module-level PyO3 function pair (setter and getter, since a setter alone cannot be tested end to end), applied once in DjustConfig.ready() from config. Module-level rather than a RustLiveView method, because the Rust side is a process-global AtomicBool unlike the per-view set_loop_render_cache_enabled (#1967), and driving a process global from a per-view hook is last-view-wins; and deliberately outside the PYTEST_CURRENT_TEST guard, so the flag holds the same value in tests as in production. The default stays False — iteration 3 is not shipped.ROADMAP.md gates the flip on real browser evidence, so a [dj-virtual] demo was built and driven in Chrome. Inserting a row at server position 5 with the window scrolled away put it at pool index 60 — the tail — with the flag on and off alike, where 5 is wanted. (Pool size goes 60→61 either way, which is why ADR-026 warns to assert order and position rather than counts.) Filed as #2164; #2017 stays open. Both diagnoses recorded here were wrong, and the third is the one that held. The first said the list was not windowed at patch time; that was withdrawn after review pointed out the experiment's control arm had also failed, which cannot support any cause. The second said iteration 2's client applier ignored before_key and appended at the tail. It does not. The differ and the applier were both correct all along — the flag never reached Rust, because the config read that was supposed to set it returned a default (see #2164/#2166 under Fixed), so the browser was running the feature OFF while the settings said ON. on and off alike was the tell and it was read as "the flag is irrelevant" rather than "the flag is not connected". With the config path fixed, an insert at server position 5 lands at pool index 5 (k3, k4, ins0, k5, …). The cheap signal that would have settled it two rounds earlier: no client-side warning ever fired, and every failure path in the patch dispatcher and the pool applier logs one — silence meant the op was never sent, which pointed at the server, not the client. The demo page ships too — the project had no [dj-virtual] example, and this one makes the failure legible on screen rather than only in a DOM dump. 8 cases; gate-off neutering the startup applier fails 2.

Changed

  • [dj-virtual] keyed splice ops are ON by default (ADR-026 iteration 3, #2017).LIVEVIEW_CONFIG['virtual_keyed_ops'] now defaults to True, so the differ emits VirtualInsert/VirtualMove/VirtualRemove for windowed containers and the client splices them at their keyed position. ROADMAP gated this on real browser evidence, and the evidence kept being unobtainable because the control arm was as broken as the test arm — two bugs had to land first (#2185, djustInit ran mid-bundle so the list was never virtualized on cached loads; #2194, post-mount reinit was rAF-only so a hidden tab never recovered from the mount morph). Until both were fixed, every earlier A/B compared OFF against OFF, which is how this feature accumulated four withdrawn diagnoses. With a healthy list the comparison is finally valid — same start state, flag the only variable: an insert at server position 5 lands at pool index 60 (the tail) with it off and 5 with it on; a removal leaves the pool unchanged off and drops the right key on; a reverse is ignored off and exact on; a content edit on a scrolled-out row is correct either way. So OFF is broken for every mid-list structural mutation on a windowed list, and ON fixes all three without regressing the one case OFF got right. Scope is narrow: the ops are emitted only for a container carrying dj-virtual, so projects not using it see no wire change. Version skew degrades rather than breaks: a cached pre-1.1.0 bundle against a newer server receives ops its applier does not know, which takes the existing patch-failure path (request recovery HTML and morph). Opt out with LIVEVIEW_CONFIG['virtual_keyed_ops'] = False.

  • Six artifacts asserting a withdrawn [dj-virtual] diagnosis now record the real state (#2017, #2185).crates/djust_vdom/src/diff.rs, python/djust/config.py, docs/adr/026-dj-virtual-differ-awareness.md, python/djust/tests/test_virtual_keyed_config_2017.py, the demo page banner and the demo index card all still told readers the keyed splice ops were broken — variously "the client applier does not honour it yet", "a real page is not windowed at patch time … turning this on today changes nothing", and a red FAILING badge captioned "KNOWN FAILURE". Those were #2164's first and second diagnoses; PR #2167 withdrew both, having found the differ and the applier were correct all along and the flag simply never reached Rust. Re-measured in a browser with the flag genuinely ON and the list confirmed initialised first: an insert at server position 5 lands at pool index 5, a remove drops the right key with no duplicates, a reverse is exact, and an edit lands on its own row only — every case Patches applied successfully, no recovery round-trip. The default was still OFF at that point, for the actual reason: #2185, [dj-virtual] initialisation is intermittently lost on page load, and on such a load VirtualInsert hits an uninitialised container and forces a full HTML recovery, where the OFF path degrades silently — measured by forcing that state with teardownVirtualList and confirming the server falls back to an ordinary InsertChild that applies with no console errors (#2185 and #2194 both shipped shortly after, and the default flipped ON — see the iteration-3 entry above.) No behaviour change.

Fixed

  • postcss 8.5.20 → 8.5.25, closing a source-map path traversal (GHSA-fxqj-rqcc-2cmp, Dependabot #147). An incomplete fix of GHSA-6g55-p6wh-862q: an attacker-controlled sourceMappingURL reads arbitrary .map files when from is unset. Dev-scope — postcss reaches the tree only as a transitive dependency of vite (via vitest), whose ^8.5.16 range already admits the patched version, so nothing else moves. Folded into this PR rather than shipped separately because it edits the same package-lock.json and would otherwise conflict for no benefit.

  • CI moved to Node 24, unblocking jsdom 30 and closing five open undici advisories (#2169, #2178). Dependabot's jsdom 29.1.1 → 30.0.0 bump failed every VDOM test file with TypeError: webidl.util.markAsUncloneable is not a function, thrown from undici's own cachestorage.js while jsdom's api.js was still loading. The lockfile was not at fault — jsdom 30 requires undici ^8.7.0, and dependabot's lockfile resolves undici to 8.9.0, which is exactly the patched version for all five open advisories (GHSA-4cwx-7wf7-3272, -8xcm-r25x-g524, -jr45-8vmc-qm54, -v3r7-h72x-cjcm, -m8rv-5g2x-5cg5 — each patched at 8.9.0 for the >= 8.0.0 range). These are dev-scope: undici reaches the tree only through jsdom, so no shipped artifact was ever affected. The runner was at fault.markAsUncloneable is a node:worker_threads API added in Node 22.10, and undici 8.9.0 declares engines.node >=22.19.0, but all three setup-node steps pinned node-version: '20' — itself past EOL (2026-04-30), so the bump was due regardless. Now '24', the current Active LTS, rather than 22 (in maintenance since Oct 2025). Both directions were reproduced locally rather than inferred: on Node 20.19.0 the failure is byte-identical to CI (169 of 170 files down), and on Node 24.19.0 with the same lockfile it clears. That left exactly one genuine jsdom 30 behaviour change, and it was ours: 25-page-metadata.js stored CSS.escape as a detached reference, and jsdom 30 generates CSS from WebIDL and brand-checks the receiver, so the detached call throws 'escape' called on an object that is not a valid instance of CSS (5 tests). The module was the odd one out of three — 03-websocket.js:29 and 45-child-view.js:58 both already call it as a method — so this is #1646 again, an invariant held correctly in two of three places. No source-grep pin was added: the 5 behavioural cases in page-metadata.test.js go red without the fix under jsdom 30, making a grep a strict subset with false-positive hazards, the same reason #2167 removed one. 170/170 files and 1919/1919 tests green on Node 24, with the bundle init-order lint, cross-IIFE guard and eslint --max-warnings 0 clean.

  • LIVEVIEW_CONFIG was ignored entirely when a project's asgi.py imported djust before setting DJANGO_SETTINGS_MODULE (#2164, #2166).djust.config.config is built at module import and reads Django settings exactly once. If that import happens before the settings module is named, the read raises ImproperlyConfigured, a broad except swallows it, and the singleton serves pure defaults for the life of the process — every LIVEVIEW_CONFIG key silently ignored. It fails asymmetrically: correct in tests, which import late, wrong in that server. DjustConfig.ready() now calls config.ensure_settings_loaded() once, before any reader; Django runs ready() strictly after settings resolve, so it is the one place a recovery read is guaranteed safe. Deliberately notreset(), which restores defaults first and would discard a programmatic config.update() made between import and ready() — the recovery is a no-op whenever the import-time load succeeded, so the healthy path cannot be clobbered. Two corrections to the first pass of this fix, both from Stage 11 review. Its stated mechanism was wrong: this has nothing to do with app-registry population, and the entry's claim that the flag was ignored by "every server" was false — settings is a LazySettings that resolves the moment DJANGO_SETTINGS_MODULE is set, so the djust new scaffold, which sets it first, was never affected; a scaffold-order control arm proves the pre-fix code applied the flag correctly. And the fix was 2-of-N: four more kill-switches were inert under the same conditions, two of them in the file that first pass edited — loop_render_cache_enabled (mixins/rust_bridge.py:307), template_auto_call (:330), hot_reload_auto_enable and filter_bridge_warm (both apps.py). loop_render_cache_enabled is the sharp one: default-ON since #2062, so it was a user's only way to opt out of the loop render cache, and setting it did nothing. Fixing them per call site is the shape #1646 exists to reject, so the recovery is structural and fixes all five at once. virtual_keyed_ops accordingly goes back to reading the singleton, which also restores the documented DJUST_CONFIG fallback (#1993) that a direct-settings read skips — a value set there would have read ON through config.get() while the differ ran OFF, a new silent asymmetry of exactly the class being fixed — and keeps _defaults the single source of the default, which matters because ADR-026 iteration 3 is literally "flip that default". Also fixes examples/demo_project's own asgi.py, which imported demo_project.routing (→ djust.websocketdjust.config) three lines above its setdefault, and whose launcher does not export the variable either: the demo — our dogfood surface, and where #2164's browser evidence was gathered — had been running with every config key pinned to defaults. The reproduction has to be a subprocess: the property is import order, and by the time pytest has imported anything djust.config is long since loaded with settings available. That is precisely why the original bug shipped green — the tests monkeypatched config._config and never exercised the order a server has. Gate-off: disabling the recovery turns the broken-order test and the ordering pin RED while the scaffold-order control stays green. A source-grep pin from the first pass was removed rather than kept: review measured it green against three reintroductions of the defect in other shapes (get_config(), a module-attribute read, and one where only a comment carried the tokens it searched for — the code_only hole, 7th recurrence) and red against correct code whose comment merely mentioned the banned string, making it a strict subset of the behavioural test with false-positive hazards.

  • Post-mount work never ran when a page was loaded in a hidden tab (#2194).03-websocket.js scheduled the whole post-mount block — reinitAfterDOMUpdate(), window.djust._mountReady, reconnect form-recovery and dj-auto-recover — through a single requestAnimationFrame, and browsers do not fire rAF in a hidden tab. Its else branch only covered "no rAF function at all", which never happens in a browser. Pages are routinely hidden at mount: opened in a background tab, restored with the session, or prerendered. Measured in a real hidden tab — before: _mountReady: undefined, [dj-virtual] shell gone, all 60 rows rendered, and a queued rAF still pending seconds later; after: _mountReady: true, shell present, 13 rows. Now runs immediately when document.hidden (there is no paint in progress, so FIX #619's reason to defer does not apply), leaving the visible path unchanged. A 250 ms timeout races the rAF as a backstop for occluded or minimised tabs where hidden is false but rAF is still throttled — first one wins, guarded so post-mount cannot run twice and re-enter form recovery. The 250 ms is deliberate: a setTimeout(0) backstop would beat rAF's ~16 ms on every visible mount and silently undo #619. Two pre-existing #619 pins failed on this change and were rewritten to pin the invariant rather than the literal call shape; deleting the defer still turns them red. Second half of #2185 — which was two bugs wearing one symptom.

  • dj-virtual, infinite-scroll and colocated hooks silently never initialised on cached/warm page loads (#2185).14-init.js is module 14 of 55 in one concatenated IIFE, and it ran djustInit()synchronously whenever document.readyState !== 'loading' — i.e. while modules 15–55 were still unexecuted. djustInit() reaches extractColocatedHooks (module 32), initVirtualLists (29) and initInfiniteScroll (30) through if (window.djust.X) existence guards, so on that branch all three silently skipped: no error, no warning, no self-heal. It presented as intermittent because it depends on readyState at that instant — a cold parse during HTML streaming is 'loading' (safe branch), a warm or cached load is already 'interactive' and takes the broken one. Measured on a failing load: readyState "interactive", typeof window.djust.initVirtualLists === "undefined", and initVirtualLists never called at all. Now queueMicrotask(djustInit) — the rest of the bundle is synchronous, so it is guaranteed to complete before any microtask drains, costing no frame and no paint. queueMicrotask, not Promise.resolve().then(…): the promise form would convert an init throw into a silent unhandled rejection and disarm the #1370 TDZ guard, which is exactly how the first version of this fix was caught. Neither bundle lint detects this class — both report clean on the broken form, because they look for bare references and if (window.djust.X) is not one. Note #1370 patched a symptom of this same root cause (a module-19 let read during the synchronous bootstrap → TDZ ReferenceError) by switching it to var; that workaround stays as belt-and-braces. 7 new cases; gate-off reverting to the bare djustInit() turns all 9 cases across both files red.

  • cargo clippy failed the whole workspace on Rust 1.97, blocking every commit. Two pre-existing sites tripped lints that only became errors on the newer toolchain: useless_borrows_in_formatting on a &self.content inside a format! (crates/djust_components/src/simple/tooltip.rs:62) and question_mark on a match whose arms were only Some(x) => {…} / None => return None (crates/djust_live/src/lib.rs:1324, now let roots = loop_cache.get_parsed(h)?;). Both are behaviour-preserving — get_parsed returns an owned clone, so ? changes neither the borrow shape nor how often parse_hits is bumped. This is not a local-only nuisance: the pre-commit hook runs cargo clippy --all-targets --all-features -- -D warnings, so no commit can land on a 1.97 toolchain (it silently swallowed three before the full hook output was read), and CI pins toolchain: stable (.github/workflows/test.yml:35, :109), so it becomes a CI failure the moment the runner's stable advances. Surfaced by repairing a broken local toolchain — Homebrew rust 1.93's librustc_driver linked libLLVM 21.1.8 while /opt/homebrew/opt/llvm had moved to 22.1.8, so rustc -vV itself aborted.

Security

  • A client-supplied parameter name could write an unbounded log line on every event (CodeQL py/log-injection#2579). The alert's own framing — forged log records via CR/LF — was already handled: the near-miss warning uses %r, and repr() escapes CR/LF. The bug was in the claim the code made in its defence, that "key and match need no length cap: a near miss must be a prefix/suffix of a declared parameter, so neither can be long." _is_near_miss normalises before it compares, and three routes falsify that against a declared field_name: it strips underscores from both sides, so f_i_e_l_d_n_a_m_e + 20 000 underscores compares equal; when the key is the longer side it becomes long_, so field_name + 20 000 of anything matches via startswith; and likewise fieldname + 20 000 digits, with no underscore at all. Parameter names come straight off the wire, so uncapped this is an unbounded client-controlled write to the log on every event — repr() bounds nothing. The warn-once memo was not a mitigation but a second instance of the same bug: its key is (module, qualname, key), so varying the padding mints a fresh entry per request instead of deduplicating, and it retained the full key — 512 entries able to hold 20 KB apiece. That is precisely the memory-exhaustion vector test_the_cache_is_bounded_against_client_controlled_names exists to close, left open because it bounds the entry count using short names and never the entry size. Both are now capped once into safe_key; _is_near_miss still sees the full key, so matching semantics are unchanged, and for a normal identifier sanitize_for_log is the identity, so the common path is untouched. match stays raw and is now documented as the one field there that is not client-influenced. The same false claim was asserted in the test file too ("capping the key is inert") and is corrected. New cases in test_a_long_parameter_name_is_truncated (3, one per route) and test_the_cache_stores_bounded_keys_not_client_sized_ones; gating off the log-argument cap fails the first three while the memo case stays green, and gating off the memo cap fails only the memo case — the two mechanisms are independently reachable rather than shadowing each other.

  • cryptography 49.0.0 → 50.0.0, closing a Bleichenbacher oracle in PKCS#7 EnvelopedData decryption (Dependabot #146, high). The advisory is that PKCS#7 decryption distinguishes padding failures through both error text and timing, which is the classic adaptive-chosen-ciphertext oracle. This is the only runtime-scope alert open against the project. The bump does not stand alone: the locked pyOpenSSL 26.3.0 pins cryptography<50,>=49.0.0, so cryptography 50 is unreachable until pyOpenSSL moves to 26.4.0 (cryptography<51,>=49.0.0) — both are upgraded together, and the other five dependents (autobahn, service-identity, pyjwt, google-auth, azure-storage-blob) already accept it. Verified rather than assumed: 10227 passed / 218 skipped with the upgraded pair installed, plus a direct smoke test of the consumer surface (RSA sign/verify round-trip, OpenSSL.SSL.TLS_METHOD context construction, and service_identity.pyopenssl — daphne's TLS verification path).

  • uv.lock registry URLs restored to https://pypi.org/simple. Every one of the 125 entries had been rewritten to a developer-local caching proxy (http://127.0.0.1:8418/pypi/simple/), which leaked into the committed lockfile in e39a9242 ("chore: clean working tree for release") — before that commit the file correctly recorded https://pypi.org/simple. It is machine-local state from a global ~/.config/uv/uv.toml, meaningless to anyone else and unreproducible off that machine. Nothing was broken in practice, because uv resolves from the per-wheel url fields (which always pointed at files.pythonhosted.org), which is why CI never noticed. Restored as a side effect of re-locking with an explicit index; the 125-line delta is mechanical and independent of the version bump above.

  • sqlparse 0.5.5 → 0.6.0 — four advisories, all runtime-scope (#150#153). Three high (ReDoS via dollar-quoted literals; an O(subtree) materialization per group in TokenList.__init__ that burns CPU before the depth/token caps can trigger; quadratic O(n²) in group_comments) and one moderate (SQL string breakout through unescaped backslashes in generated Python/PHP snippets). All patched in 0.6.0. sqlparse is transitive via Django, which asks only for sqlparse>=0.3.1, so nothing else moves — the lockfile diff is a single version line. Verified past the resolver: format/split/parse behave and Django's own consumer works (sqlmigrate emits SQL).

  • uv.lock no longer records a developer-local package index, and a guard now keeps it that way. All 125 entries read https://pypi.org/simple again. This has leaked three times — introduced in e39a9242, removed in 231e966e, then reintroduced by the 1.1.0 release commit 5fe74931, where the lockfile was checked immediately after make version and found clean, then rewritten by a later uv call in the same session whose shell no longer carried the UV_INDEX_URL override. Checking the file before the commit is not checking the commit. The failure is silent by construction: uv installs from the per-wheel url fields (always files.pythonhosted.org), so installs succeed and CI stays green while the lockfile names a host only one machine can reach. scripts/check-lockfile-registry.py now rejects any non-public registry, wired into pre-commit; its canary runs against the actual leaked lockfile recovered from 5fe74931 rather than a synthetic fixture, and gating the guard off (including merely widening its allowlist to admit the leak) turns the suite red.

All releases · Atom feed