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_opswas 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 inDjustConfig.ready()from config. Module-level rather than aRustLiveViewmethod, because the Rust side is a process-globalAtomicBoolunlike the per-viewset_loop_render_cache_enabled(#1967), and driving a process global from a per-view hook is last-view-wins; and deliberately outside thePYTEST_CURRENT_TESTguard, so the flag holds the same value in tests as in production. The default staysFalse— iteration 3 is not shipped.ROADMAP.mdgates 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 ignoredbefore_keyand 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 alikewas 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 toTrue, so the differ emitsVirtualInsert/VirtualMove/VirtualRemovefor 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,djustInitran 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 carryingdj-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 withLIVEVIEW_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 casePatches 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 loadVirtualInserthits an uninitialised container and forces a full HTML recovery, where the OFF path degrades silently — measured by forcing that state withteardownVirtualListand confirming the server falls back to an ordinaryInsertChildthat 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
postcss8.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-controlledsourceMappingURLreads arbitrary.mapfiles whenfromis unset. Dev-scope — postcss reaches the tree only as a transitive dependency ofvite(via vitest), whose^8.5.16range already admits the patched version, so nothing else moves. Folded into this PR rather than shipped separately because it edits the samepackage-lock.jsonand would otherwise conflict for no benefit.CI moved to Node 24, unblocking jsdom 30 and closing five open
undiciadvisories (#2169, #2178). Dependabot's jsdom29.1.1 → 30.0.0bump failed every VDOM test file withTypeError: webidl.util.markAsUncloneable is not a function, thrown from undici's owncachestorage.jswhile jsdom'sapi.jswas still loading. The lockfile was not at fault — jsdom 30 requiresundici ^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 at8.9.0for the>= 8.0.0range). These are dev-scope: undici reaches the tree only through jsdom, so no shipped artifact was ever affected. The runner was at fault.markAsUncloneableis anode:worker_threadsAPI added in Node 22.10, and undici 8.9.0 declaresengines.node >=22.19.0, but all threesetup-nodesteps pinnednode-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.jsstoredCSS.escapeas a detached reference, and jsdom 30 generatesCSSfrom 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:29and45-child-view.js:58both 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 inpage-metadata.test.jsgo 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 andeslint --max-warnings 0clean.LIVEVIEW_CONFIGwas ignored entirely when a project'sasgi.pyimported djust before settingDJANGO_SETTINGS_MODULE(#2164, #2166).djust.config.configis built at module import and reads Django settings exactly once. If that import happens before the settings module is named, the read raisesImproperlyConfigured, a broadexceptswallows it, and the singleton serves pure defaults for the life of the process — everyLIVEVIEW_CONFIGkey silently ignored. It fails asymmetrically: correct in tests, which import late, wrong in that server.DjustConfig.ready()now callsconfig.ensure_settings_loaded()once, before any reader; Django runsready()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 programmaticconfig.update()made between import andready()— 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 —settingsis aLazySettingsthat resolves the momentDJANGO_SETTINGS_MODULEis set, so thedjust newscaffold, 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_enableandfilter_bridge_warm(bothapps.py).loop_render_cache_enabledis 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_opsaccordingly goes back to reading the singleton, which also restores the documentedDJUST_CONFIGfallback (#1993) that a direct-settings read skips — a value set there would have read ON throughconfig.get()while the differ ran OFF, a new silent asymmetry of exactly the class being fixed — and keeps_defaultsthe single source of the default, which matters because ADR-026 iteration 3 is literally "flip that default". Also fixesexamples/demo_project's ownasgi.py, which importeddemo_project.routing(→djust.websocket→djust.config) three lines above itssetdefault, 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 anythingdjust.configis long since loaded with settings available. That is precisely why the original bug shipped green — the tests monkeypatchedconfig._configand 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 — thecode_onlyhole, 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.jsscheduled the whole post-mount block —reinitAfterDOMUpdate(),window.djust._mountReady, reconnect form-recovery anddj-auto-recover— through a singlerequestAnimationFrame, and browsers do not fire rAF in a hidden tab. Itselsebranch 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 whendocument.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 wherehiddenisfalsebut rAF is still throttled — first one wins, guarded so post-mount cannot run twice and re-enter form recovery. The 250 ms is deliberate: asetTimeout(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.jsis module 14 of 55 in one concatenated IIFE, and it randjustInit()synchronously wheneverdocument.readyState !== 'loading'— i.e. while modules 15–55 were still unexecuted.djustInit()reachesextractColocatedHooks(module 32),initVirtualLists(29) andinitInfiniteScroll(30) throughif (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 onreadyStateat 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", andinitVirtualListsnever called at all. NowqueueMicrotask(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, notPromise.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 andif (window.djust.X)is not one. Note #1370 patched a symptom of this same root cause (a module-19letread during the synchronous bootstrap → TDZReferenceError) by switching it tovar; that workaround stays as belt-and-braces. 7 new cases; gate-off reverting to the baredjustInit()turns all 9 cases across both files red.cargo clippyfailed 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_formattingon a&self.contentinside aformat!(crates/djust_components/src/simple/tooltip.rs:62) andquestion_markon amatchwhose arms were onlySome(x) => {…}/None => return None(crates/djust_live/src/lib.rs:1324, nowlet roots = loop_cache.get_parsed(h)?;). Both are behaviour-preserving —get_parsedreturns an owned clone, so?changes neither the borrow shape nor how oftenparse_hitsis bumped. This is not a local-only nuisance: the pre-commit hook runscargo 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 pinstoolchain: 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'slibrustc_driverlinked libLLVM 21.1.8 while/opt/homebrew/opt/llvmhad moved to 22.1.8, sorustc -vVitself 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, andrepr()escapes CR/LF. The bug was in the claim the code made in its defence, that "keyandmatchneed no length cap: a near miss must be a prefix/suffix of a declared parameter, so neither can be long."_is_near_missnormalises before it compares, and three routes falsify that against a declaredfield_name: it strips underscores from both sides, sof_i_e_l_d_n_a_m_e+ 20 000 underscores compares equal; when the key is the longer side it becomeslong_, sofield_name+ 20 000 of anything matches viastartswith; and likewisefieldname+ 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 vectortest_the_cache_is_bounded_against_client_controlled_namesexists to close, left open because it bounds the entry count using short names and never the entry size. Both are now capped once intosafe_key;_is_near_missstill sees the full key, so matching semantics are unchanged, and for a normal identifiersanitize_for_logis the identity, so the common path is untouched.matchstays 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 intest_a_long_parameter_name_is_truncated(3, one per route) andtest_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.cryptography49.0.0 → 50.0.0, closing a Bleichenbacher oracle in PKCS#7EnvelopedDatadecryption (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 lockedpyOpenSSL 26.3.0pinscryptography<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_METHODcontext construction, andservice_identity.pyopenssl— daphne's TLS verification path).uv.lockregistry URLs restored tohttps://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 ine39a9242("chore: clean working tree for release") — before that commit the file correctly recordedhttps://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, becauseuvresolves from the per-wheelurlfields (which always pointed atfiles.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.sqlparse0.5.5 → 0.6.0 — four advisories, all runtime-scope (#150–#153). Three high (ReDoS via dollar-quoted literals; anO(subtree)materialization per group inTokenList.__init__that burns CPU before the depth/token caps can trigger; quadraticO(n²)ingroup_comments) and one moderate (SQL string breakout through unescaped backslashes in generated Python/PHP snippets). All patched in 0.6.0.sqlparseis transitive via Django, which asks only forsqlparse>=0.3.1, so nothing else moves — the lockfile diff is a single version line. Verified past the resolver:format/split/parsebehave and Django's own consumer works (sqlmigrateemits SQL).uv.lockno longer records a developer-local package index, and a guard now keeps it that way. All 125 entries readhttps://pypi.org/simpleagain. This has leaked three times — introduced ine39a9242, removed in231e966e, then reintroduced by the 1.1.0 release commit5fe74931, where the lockfile was checked immediately aftermake versionand found clean, then rewritten by a lateruvcall in the same session whose shell no longer carried theUV_INDEX_URLoverride. Checking the file before the commit is not checking the commit. The failure is silent by construction:uvinstalls from the per-wheelurlfields (alwaysfiles.pythonhosted.org), so installs succeed and CI stays green while the lockfile names a host only one machine can reach.scripts/check-lockfile-registry.pynow rejects any non-public registry, wired into pre-commit; its canary runs against the actual leaked lockfile recovered from5fe74931rather than a synthetic fixture, and gating the guard off (including merely widening its allowlist to admit the leak) turns the suite red.