v1.1.0rc9
Pre-releaseChanged
-
The benchmarks job now fails the aggregate
test-summarycheck (#2160). It shippedcontinue-on-error: trueunder canon #1534 — a CI job exercising an environment the dev machine cannot reproduce stays non-gating until green on the runner — and has since been green six consecutive times across four refs, two of themmainpushes, at ~13% of budget (vdom_diff_list_reordermedian 662 µs against its 5 ms target, more headroom than a dev machine has). One green run satisfies #1534's letter; six independent ones satisfy its intent. This matters because job-levelcontinue-on-errormakesneeds.benchmarks.result == 'success'even on failure, so until now the job proved the thresholds could be enforced without enforcing them. Promotion is all-or-nothing: the flag removed, the job added totest-summary'sneedsand to its AND-chain (the load-bearing step — inneedsalone it looks enforced and gates nothing, #1713), and the non-blocking pin inverted rather than deleted. Deliberately not described as "blocking the merge": review found thatmainhas no required status checks at all, so a redtest-summarydoes not stop a merge for any check in this repo — #1713 one level up, filed as #2163 rather than changed as a side effect of a benchmark PR. The pin also now rejects a step-levelcontinue-on-error, which defeats the gate identically and is an idiom this same workflow uses four times. -
The sticky-child persistence tests no longer assert a time-bounded save as if it were guaranteed (#2154).
runtime.pywraps the event-path state save inasyncio.wait_for(..., 0.150)(#1475) — deliberately, so a slow session backend can never stall event handling — which makes the save best-effort: when the session write exceeds 150 ms theTimeoutErroris swallowed, a warning is logged, andliveview_<path>never lands. Five tests asserted that key is present, so they passed on every quiet machine and failed on a loaded one. A correction to the earlier diagnosis: this was reported as failing with all three test roots and passing with any two — a clean distribution signal. That bisection was confounded by concurrent worktree agents; the failure is load-dependent, not distribution-dependent. Established properly: unreproducible in 9 consecutive runs at every worker count from-n 2to-n 12, unreproducible at the original failing commit (so nothing had silently fixed it), and reproducible on demand under 24 CPU spinners plus 3 IO writers. The suite also grew from 10,203 to 10,213 tests during the drain, reshuffling xdist and independently masking it — two separate reasons it looked fixed while the cause was untouched. The 150 ms literal is nowEVENT_STATE_SAVE_TIMEOUT_S, named so a test asserting a save landed can raise it and exercise the save logic rather than race a clock; agenerous_save_timeoutfixture inpython/djust/tests/conftest.pydoes that for all 13 affected tests across four files — includingtest_ws_event_save_block_writes_through_to_session, the non-tautological integration test canon #1468 cites. The first version covered 4 and claimed a symbol-migration grep as the scope check; that grep was true of the string and false of the bug class, which is #1543 — single-variant coverage of a multi-variant surface is worse than none, because it makes the class look handled. The class is now enumerated empirically (cut the bound to 0.1 ms, run all three roots) rather than by grep, and the verification is decisive: with the bound cut, the only failure across the whole suite is the pin that exists to detect a changed bound. Production behaviour is unchanged — the bound is still 0.150 s — and pins keep it that way, since "raise it in tests" must not become "drop it in production": the bound is pinned to 0.150 exactly (a range alone let it drift to 0.999 s with every pin green), call-site arithmetic such as* 200is rejected, and both handlers must log and must not re-raise. That last pin originally inspected only the first handler and only the text preceding the log, so a silently-dropped save at the second site andlogger.warning(...); raiseat either site both survived it. Verified with 3/3 clean full-suite runs and 10,215 passed under the load that reproduced the failure; production gate-off fails on removing the bound, leaving either site unbounded, raising it to 30 s, or neutering either handler. -
The benchmark thresholds are now enforced by a job that exists (#2156).
tests/benchmarks/conftest.pytold readers that "the benchmark-gated CI job (--benchmark-onlyserial) enforces it". It did not: a grep for--benchmark-onlyacross all twelve workflows returned nothing, and that job had never existed. Because_assert_benchmark_underskips its threshold wheneverbenchmark.statsis unavailable — which is what pytest-benchmark does under xdist, and every CI job plusmake testrun-n auto— the only context enforcing these latency bounds was the serial pre-push hook, i.e. a process that had just executed 10,000 tests, where a warm and fragmented heap makes the median systematically slower. That is how a threshold no CI job checked came to block every push onmainwhile measuring the environment rather than the code (found byscripts/pre-push-pytest.shon its first real use). The targets were measured, not adjusted: serially on a quiet machine all 58 benchmarks pass, withvdom_diff_list_reorderat a 3.78 ms median against its 5 ms target — the tightest margin in the suite, which is both why it flipped under load and why it will catch a real regression first. So no bound was loosened; reaching for a bigger number is the reflex that turned a 10 ms threshold into 100 ms intest_redis_serialization_performanceand flaked again. Abenchmarksjob now runs them serially with thresholds enforced, explicitly not under-n auto(which would silently disable the stats it exists to assert on). It shipscontinue-on-error: trueand outsidetest-summary's AND-condition per canon #1534 — a timing gate on a shared runner proves itself before it can block a merge — and promotion is pinned as all-or-nothing so the half-promoted state, where a job sits inneedsand looks enforced without gating anything (#1713), cannot ship. 7 cases intest_benchmark_enforcement_2156.py. Stage 11 found the safety claim above to be false and proved it with a green suite:test_promotion_is_all_or_nothingscanned the whole workflow file, andtest-summary's echo block mentions every job — so a reviewer shipped the complete half-promoted state (job de-flagged, added toneeds, echoed in the summary, absent from the AND-chain) with 0 failures. The scan is now scoped to the AND-chain itself; that ship fails 2, while a genuine promotion fails only the non-blocking pin that is written to be inverted deliberately. Also from that review: the pin file usedimportorskip("yaml"), which made all seven checks evaporate as1 skippedwith the suite green (PyYAML is undeclared, arriving viauvicorn[standard]); the conftest docstring quoted a contended laptop's 3.78 ms in a sentence naming the CI job, where the real figure is 655 µs — 13% of budget, not 68% — and now records the ~11× environmental spread with an explicit do not tune these against a local run; and the install hand-rolledpip install uvwhile six sibling jobs in the same file useastral-sh/setup-uv@v7, the #1646 drift class that #2155's own entry names. Promotion is tracked in #2160, with the runner evidence that its precondition is already met. -
A wall-clock test assertion replaced with a call count, and the flake cluster behind it measured (#2154).
test_redis_serialization_performanceassertedset_time < 0.1andget_time < 0.1on wall clock. Its own docstring already recorded the diagnosis — the original 10 ms bound "flaked under heavy suite load" and had been raised to 100 ms — so the threshold had been loosened rather than the shape changed, and it flaked again at 100 ms. That is the pattern this repo's canon names explicitly (#1795, and the median-vs-mean benchmark rule): no threshold is immune to a saturated machine when the assertion is on an outlier-sensitive quantity. The docstring also states what the test is actually for — catching "accidental double-serialization" — which is a call count, not a duration. It now assertsserialize_msgpack/deserialize_msgpackare each invoked exactly once perset/get, plus a round-trip correctness check so a backend that serialized once and returned garbage cannot pass. Strictly stronger, not merely steadier: adding a secondserialize_msgpack()call fails the new test immediately and would not have failed the old one, since serializing 1 KB twice is microseconds against a 100 ms bound — the assertion could only ever have fired on scheduling noise, which is exactly what it did. Verified load-independent at 2× CPU saturation. Investigating it showed the intermittent failure was not one test but at least four (~1 full-suite run in 3 under-n auto), so #2154 now tracks the class;test_time_travel_jump_recovery_version_is_currentcould not be reproduced in 23 consecutive runs and so was given frame-sequence diagnostics rather than a guessed fix, since a phantom fix for an unpinned cause is worse than none. -
The main-health workflow's install now mirrors the proven CI recipe (#2155). Its first scheduled run failed and filed "main-health check could not run" — the three-state logic working exactly as intended, since it did not claim main was red and send people hunting for a regression that does not exist — but the job itself was broken. The cause was a second, hand-rolled install:
uv venvcreates nopip, so.venv/bin/pip install maturinhad no interpreter, and the line was redundant anyway because maturin is already a dev dependency the precedinguvinstall had placed. Replaced with theuv sync --extra dev/uv run maturin develop --releasepair thattest.ymlproves every run; a second install path for one project is the parallel-path drift class (#1646), so the pin asserts both halves agree and fails iftest.ymlmoves off it. -
The Action Tracker's open count now means something, and chain-shaped issues have a convention (#2143, #2142). Nothing closed a
RETRO.mdAction Tracker row when its GitHub issue closed, so the open count drifted upward and every retro's Stage 5 re-derived the true state by hand — row 325 sat atOpenagainst an issue closed a full milestone earlier and was found by chance. The count is quoted in retros as a health signal; running the newscripts/check-action-tracker.pyagainst the real file found 65 of 70Openrows whose issue was already closed. The true figure was 5, so the signal was wrong by more than an order of magnitude and made a drained backlog look untouched. The script parses the table, fetches every issue's state in oneghcall, and reports disagreement in both directions — the reverse case (aClosedrow whose issue was reopened) is the more dangerous one, since it hides live work. Report-only by default, because a bare status flip discards the half of the row that makes it useful later;--fixtherefore looks up the closing PR and writes it as the reason, and never overwrites a reason a human wrote. The 65 rows are corrected here, with the edit verified to have touched only the Status and Notes cells. It is deliberately not a blocking CI gate — it needs the network, and drift between a merge and the next retro is normal — but it refuses to be quietly useless: an unreachable GitHub or an unparseable table exits 2 rather than reporting zero drift, since "nothing was checked" reading as "all clean" is the exact failure it exists to end. 19 offline cases; gate-off found a defect in the fix itself, where two redundant guards masked each other so deleting either alone left the suite green. Separately,CLAUDE.mdgains a convention for chain-shaped issues — those whose root cause is a class rather than an instance, where each PR's review surfaces the next link (#2107 → #2109 → #2111; four stream links; #2129's five rounds; #2139's three, two of whose defects were introduced by the previous round's fixes). Mark them chain-suspected at Stage 4, file link N+1 immediately with a back-reference, and count a chain as one finding and N PRs in the retro. Do not estimate one up front: by construction you cannot, and pretending otherwise makes honest work look late — which is the pressure that produces the batched half-diagnosis the convention exists to avoid. -
Removed the unreachable
data-field_nameattribute from every rendered form field (#2145).python/djust/frameworks.pyputdata-field_name="<name>"on every widget the CSS-framework adapters render, next todj-change="validate_field", under the comment "Pass field_name so event handler knows which field changed". It never did. The chain, traced rather than assumed:data-*is collected by exactly two client functions —extractTypedParams(08-event-parsing.js:248) and_processAutoRecover(09-event-binding.js:1684, which reads thedj-auto-recovercontainer's own attributes); everyelement.attributesiterator and every.datasetread instatic/djust/src/was checked and there is no third.extractTypedParamsruns on six call sites —dj-click(:601),dj-poll(:1206),dj-click-away(:1264),dj-shortcut(:1336),dj-mounted(:1365) anddj-window-*/dj-document-*(:319) — and the adapters emit none of those; the path they do bind,dj-change/dj-input/dj-blur/dj-focus, goes throughbuildFormEventParams(:525), which merges onlydj-value-*. So the one collector that would have read it never ran on the element carrying it. What carries the name instead isname="<field_name>", which all three renderers set unconditionally and whichgetFieldName(:504) reads as its second fallback afterdata-field; the reconnect path agrees,_processFormRecovery(:1773) readingfield.name || field.id. Nothing in the tree read the attribute either — zero test, doc, example or Rust reference. The radio site is the sharpest evidence: its assignment sat one indent level outside theif auto_validate:guard the other two kept it inside, soauto_validate=Falserendereddata-field_nameon an element carrying no djust directive at all. Why deleting something inert is worth a change entry: it is the artifact that makes a false mechanism look true. #2137 happened becauseFormMixin.validate_fieldtookfield_namewhile the client sendsfield, and PR #2141 corrected the two docstrings asserting "djust mapsdata-*attributes to handler parameters" — true of the click/poll/mount paths, false of the form-event path. Anyone openingframeworks.pysaw an attribute apparently doing exactly that and re-derived the belief. Also corrects threewizard.pydocstrings claimingas_live_field()emitsdata-field="<name>"; it does not and never did, and that claim is load-bearing here, because thenamefallback is the whole reason the deletion is safe. One boundary this does not claim:dom_event=andwidget.attrsare caller-controlled, so a caller who attaches a click-family directive to a form field does get that element'sdata-*collected — no in-repo caller, test, example, doc or demo does, and a new test goes red if a renderer ever emits one itself. 42 cases inTestNoDataFieldNameAttribute(all three renderer sites individually per #1104, across all three adapters, plus two structural pins) and 3 intests/js/no_data_field_name_2145.test.js, which makes the reachability chain executable:dj-changeyields nofield_nameeven with the attribute re-added, whiledj-clickon the same markup does. Gate-off verified per site, every mutation asserted to have applied and errors counted apart from failures — re-adding site 1 12 failed, site 2 3, site 3 6; the pins are load-bearing too (droppingname12, emittingdj-click3), as are the JS cases (mergingdata-*intobuildFormEventParams1, droppingextractTypedParamsfrom the click path 1). -
A
**kwargshandler no longer swallows a near-miss parameter silently (#2144). #2137 was one instance of a class: the client sentfield,FormMixin.validate_fieldtookfield_name, and because the signature also had**kwargsthe mismatch was absorbed instead of raisingTypeError— the handler ran on every keystroke and did nothing, with no error and no warning. Fixing two signatures fixed that instance; every djust handler with**kwargs(the documented, recommended shape) kept the same exposure.validate_handler_paramsalready computed the unexpected-key set and then discarded it whenever**kwargswas present; it now logs a warning naming the handler, the key it was sent, the similarly-named parameter it declares, and what it does accept. Only near misses are reported. An unexpected key bearing no resemblance to the signature is the documented catch-all shape (def on_event(self, **kwargs)readingkwargsdirectly), and warning on those would put noise on@input/@change— the exact paths where this has to stay quiet. The discriminator was measured rather than chosen: instrumenting the suppression point across the full suite showed 110 keys legitimately reach a**kwargshandler and the rule fires on 0 of them, while catching #2137's exact shape plus snake/camel drift, abbreviation, and prefix-drop mismatches. The framework-key denylist was enumerated the same way rather than read off call sites — exactly two non-underscore keys reach**kwargs(view_id, which survives the actor path becauseruntime.py:2734reads it with.getwhere:3457pops it, andcomponent_id); underscore-prefixed keys are excluded wholesale so a future_foocannot start generating noise. It warns once per (handler, key), so 50 keystrokes produce one line rather than fifty, and the warn-once cache is capped: parameter names come from the client, so an unbounded set is a memory-exhaustion vector for anyone who can open a socket — on reaching the cap the diagnostic switches off rather than degrading into the per-event logging it exists to avoid. Client-influenced fields log through%r, so"field_nam\r\n"cannot forge a second log record. 26 cases intest_swallowed_kwargs_warning_2144.py; 7 of 8 gate-off mutations fail at least one test, and the eighth is documented in-code as a fast path rather than a behaviour so a reader does not mistake it for a missing test. -
A
**kwargshandler no longer swallows a near-miss parameter silently (#2144). #2137 was one instance of a class: the client sentfield,FormMixin.validate_fieldtookfield_name, and because the signature also had**kwargsthe mismatch was absorbed instead of raisingTypeError— the handler ran on every keystroke and did nothing, with no error and no warning. Fixing two signatures fixed that instance; every djust handler with**kwargs(the documented, recommended shape) kept the same exposure.validate_handler_paramsalready computed the unexpected-key set and discarded it whenever**kwargswas present; it now logs a warning naming the handler, the key it was sent, the similarly-named parameter it declares, and what it does accept. Only near misses are reported — an unexpected key bearing no resemblance to the signature is the documented catch-all shape (def on_event(self, **kwargs)readingkwargsdirectly), and warning on those would put noise on@input/@change, the exact paths where this must stay quiet. The discriminator is structural, not a similarity score: a real wire/signature mismatch is either the same name modulo case and underscores (item_id/itemId, the JS/Python boundary) or one name a prefix or suffix of the other (field/field_name,checked/is_checked), with a 3-character floor. A first version useddifflibat a 0.6 cutoff and was wrong in both directions — it claimeddatawas a typo ofdate,fromofformandidofuid(confidently wrong advice about djust's own parameter names, worse than the silence it replaced), while staying silent onnameagainstfield_name(0.571) andpageagainstpage_number(0.533), which are #2137 one step further along. The structural rule is better on both axes rather than a trade, and it is explainable in one sentence to whoever reads the warning. It warns once per (module, handler, key) — keying on__qualname__alone let two apps with a same-named view class share one entry, silencing the second app's identical bug — and the warn-once cache evicts rather than switching off: a hard cap traded an expensive attack for a cheap one, since ~560 events of novel keys from a single socket would have blinded the diagnostic for every view and tenant for the process's lifetime. Positional-only parameters are excluded, because "rename one side to match" cannot be acted on for a parameter that can never be passed by name. Client-influenced fields log through%r, so"field_name\r\n"cannot forge a second log record. 42 cases intest_swallowed_kwargs_warning_2144.py; 17 of 18 gate-off mutations fail at least one test, and the eighteenth is documented in-code as a fast path rather than a behaviour so a reader does not mistake it for a missing test. -
TYPE-floor verdict memoization in
_field_type_is_excluded(perf). The #1987 TYPE-based serialization floor is consulted once per field per serialized model on the eager path — a chat-sized LiveView render is thousands of calls per event, each walking the field class MRO with casefold scans. The verdict is a pure function of the field's CLASS and the configuredsensitive_field_types, so it is now memoized per(field class, configured frozenset)key. ALIVEVIEW_CONFIGmutation lands on a NEW key (the frozenset is part of the key), so a stale verdict can never be served after a config change — verified byTestTypeFloorMemo::test_config_mutation_bypasses_stale_memo. The heuristic encrypted-field breadcrumb keeps its one-shot-per-class semantics (fires on the first computed drop; memo hits stay silent). Measured ≈1.2ms saved per chat-sized event (3k calls). 2 new cases intest_field_type_exclusion_1987.py(TestTypeFloorMemo).
Added
-
The client applies the
[dj-virtual]keyed splice ops — ADR-026 iteration 2, still dark (#2017 items 2 and 4). Iteration 1 (PR #2126) taught the differ to emitVirtualInsert/VirtualMove/VirtualRemovefor a[dj-virtual]parent; nothing could apply them. Now12-vdom-patch.jsroutes all three through29-virtual-list.js's item pool — reusing the existing pool rather than adding a second path (#1646) — because a virtual container's children are only the visible window, so a row spliced into the DOM never entersstate.items: not counted for the spacer height, not reachable by scrolling, dropped on the next render.before_keyis honoured rather than always appending, which is what #2017 item 4 lacked. Still dark: the differ's flag defaults off, so nothing changes until iteration 3 flips it after a soak (#1122). Three design points carry over from iteration 1's review, each pinned: the ops share one sort phase and keep emitted order (before_keynames an anchor an earlier op placed — splitting them into the natural Remove/Move/Insert phases breaks it, and a test asserts phase-sorting diverges); a key may be spelleddj-keyordata-key(the Rust parser reads both intoVNode.keywhile a list's ownkeyAttrdefaults todata-key, so a lookup checking one spelling silently misses); and the flush renders each touched list exactly once per batch rather than once per op, since a per-op render re-slices the window and, in variable mode, recomputes every offset — O(items) per op. The flush hangs off a singlefinallywrapping the patch loop, because that loop has four return paths plus a throw and four call sites would drift apart. Two self-inflicted bugs the tests caught: the insert arm called acreateElementFromVNodethat does not exist (the builder iscreateNodeFromVNode), so every insert silently failed; and the newitemKey()redefined the variable-height cache'sitemKey()— the later function declaration wins for the whole module — so every offset computation started reading a bare key instead of a'k:'/'i:'-prefixed one. Four existingvirtual_listtests caught the second; running only the new file would not have. 18 cases intests/js/virtual-keyed-ops-2017.test.js; gate-off verified per guard, every mutation asserting it applied before the run (patcher arms 12,before_keyignored 4, flush 1, natural phases 1,dj-keyfallback 1, duplicate-key replace 1,nodeTypeguard 1,DIRTY.clear()outside itsfinally1, node stamped onto the patch 1, anchor-miss log 1) — and two of those tests were decorative until gate-off exposed them: every assertion read the pool, which the ops mutate directly, so the render was never required; and thedj-keycase configureddj-virtual-key-attr, which makes the first lookup succeed and the fallback dead code. Bundle: +603 bytes gzipped. The cross-language differential is the load-bearing evidence: 1056 real differ outputs — iteration 1's exhaustive 4-key subset×permutation sweep plus 17 named shapes — replayed through the real client land on exactly the server's key order, with a gate-off proving that harness is non-vacuous (forcing every insertion to the tail fails 850 of them). That gap, one protocol implemented twice in two languages with nothing testing them against each other, was the whole risk of this split. Known gap, and it blocks iteration 3: content patches for a[dj-virtual]survivor are still INDEX-addressed.reconcile_virtual_keyedrecurses into survivors with a child path built from the item's absolute index, and for a windowed container that index is meaningless on the client — the emittedSetTextcarries no dj-id (text nodes have none), so it resolves purely positionally and the #2113 guard cannot see it. Measured against a real mounted list: editing rowk0after a scroll silently rewritesk7, withapplyPatchesreturningtrueand no warning. Not introduced here (iteration 1 emits them, dark) and the same shape exists on the ordinaryreconcile_keyedpath, but the earlier record of this gap named onlyInsertSubtree/MoveSubtreeand omitted plain content patches — so it is stated here rather than discovered when the flag flips. -
Keyed splice ops for
[dj-virtual]parents in the VDOM differ — ADR-026 iteration 1, dark by default (#2017 items 2 and 4). A[dj-virtual]container's children on the client are only the visible window, so index-addressed ops are meaningless for such a parent: index 7 means the 8th item to the differ and the 8th VISIBLE item to the DOM. Three new key-addressed patch variants replace them for that parent —VirtualInsert/VirtualMove/VirtualRemove— anchoring position to the NEXT key (before_key) instead of an index, which is precisely what #2017 item 4 lacked. Ships dark:set_virtual_keyed_ops()defaultsfalse, and a test pins that a[dj-virtual]parent with the flag OFF emits byte-identical patch kinds to an ordinary parent, so nothing onmainchanges until the client can apply them (iteration 2) and the flag is flipped after a soak (iteration 3). Two design calls, each verified rather than assumed:skip_serializing_ifis safe on the live path — but not for the reason first claimed here.#[serde(tag = "type")]does NOT force a map encoding under msgpack: measured,SetText { d: None }encodes as0x93(a positional FIXARRAY) and fails its own round-trip with "invalid length 2, expected 3 elements" becauseskip_serializing_ifdropped the interiord. That is the #1541 shape, it is pre-existing across everyPatchvariant, and it is latent only becauserender_binary_diff(crates/djust_live/src/lib.rs:1028) is the sole msgpack producer and has no non-test consumer. Left alone and filed as #2130 per #1079 (fix what the issue cites, file the systemic remainder), pinned as a fact inwire_protocol_snapshot.rsrather than asserted away; the two comments inlib.rsthat stated the opposite are corrected. The new variants keepskip_serializing_ifto match every sibling variant, because the LIVE path isserde_json, where it is genuinely safe; and the flag is a process-global atomic rather than threaded throughdiff_nodes, which is a pure free function reached from several entry points.apply_patchmodels the new variants properly rather than no-op'ing like the Subtree variants, since they are key-addressed and the test helper can resolve them exactly.reconcile_virtual_keyedreuseslongest_increasing_subsequence— the same minimisationreconcile_keyedalready applies — so only genuinely displaced keys move. The first pass of this change skipped LIS on the reasoning that a move is a cheap array splice in the client's pool rather than a DOM operation; self-review measured that as wrong, because the binding cost is wire size: every surviving key got aVirtualMove, so appending one item to a 50-item list emitted 50 moves, and on the 10k-row feedsdj-virtualexists for that is 10k ops for one new row — defeating the point of virtualising. The same append now emits 0 moves and 1 insert, pinned by a test that reports the count it measured. Four further defects, all found by the Stage 11 review and all silent — the reconciler itself survived ~8000 fuzzed permutations, so every one of these was in the perimeter: clearing the list emitted index-addressedRemoveChild(the routing gate wasany_new_keyed, a property of the CHILDREN, so an empty new list — clear the feed, a filter matching nothing — fell straight through to the index path; the gate is now the PARENT); unkeyed children were dropped entirely (a header or totals row lost every update with no diagnostic); duplicate keys silently lost a row (before_keyaddresses by key, so a duplicate is unaddressable — making the virtual path strictly worse than the plain one, which demotes ambiguous keys to positional diffing and warns DJE-051); and content patches targeted the wrong child when an unkeyed sibling preceded a keyed one (SetTextat path[0,0]where[1,0]was correct — the patch carries no dj-id, so the client resolved purely by path and rewrote the unkeyed sibling with the row's text). That last one is closed by the gate, which makes the shape unreachable; the reconciler additionally now carries the absolute index rather than depending on a caller's invariant, pinned by adj-if-boundary case where the two indices differ. Stated precisely because a first draft of this entry claimed thedj-ifcase was the shipped defect — the Stage 13 reviewer disproved it by restoring the verbatim pre-fix expression and watching all 20 tests pass, since that expression indexednew_nband is correct whenevernew_nbis entirely keyed. The pin is defense-in-depth, not a reproduction. The last two are now one gate (virtual_keyed_unsupported) that falls back to the plain reconcilers and warns DJE-052: the fallback is still index-addressed and still wrong for a windowed container, which is why it warns rather than passing quietly — but it no longer loses the change. Emission order is load-bearing (before_keynames an anchor an earlier op placed) and was undocumented; a test now phase-sorts the ops the way the client's_sortPatchessorts every other kind and asserts the replay diverges, so iteration 2 giving them natural phases fails there instead of in a browser. The three variants are pinned inwire_protocol_snapshot.rs— that suite exists for exactly this contract and had zeroVirtualcoverage, the v1.0.0rc4 retro finding #1 shape verbatim. 20 cases incrates/djust_vdom/tests/virtual_keyed_ops_2017.rs(including the reviewer's fuzz, kept as an exhaustive sweep over every subset × every permutation of a 4-key universe rather than left as an ad-hoc script) + 4 inwire_protocol_snapshot.rs; gate-off verified per fix; fulldjust_vdomsuite green across 25 test binaries plus doc-tests. The no-index-addressed-op property is scoped to CHILD ops:InsertSubtree/MoveSubtree— emitted for a{% if %}inside a virtual container — still carry an index. Pre-existing, not introduced here, and iteration 2's problem; recorded so it is not assumed handled. -
ADR-026 —
dj-virtualdiffer awareness (design deliverable for #2017 items 2–4). The Rust differ has no concept of a client-windowed subtree (grep -rn "dj-virtual" crates/→ zero), so the server diffs a 10 000-row list against a DOM holding ~20 nodes. Records the three consequences (off-window patches cannot land, positional paths mean different things on each side, insert position is lost), the four options with an explicit rejection rationale for each, and recommends keyed splice ops for[dj-virtual]subtrees — without authorising implementation. Deferred because the reported pain (#1988/#1989, #1724) was append-only feeds and teardown, all of which the shipped mitigations now cover; because the change touches differ, wire protocol and client applier at once, which per #1122 wants a soak behind the four PRs that just landed in12-vdom-patch.js; and because items 1 and 5 already delivered the practical value. Includes the sequencing this would need if taken (flag-gated differ change, wire-format pinning per #1448 with the serde field-position check from #1541, client reusing_virtualInsert/_virtualPrunerather than adding a second path per #1646) and the specific testing trap — assert order and position, not counts, since #2017 item 1's first tests counted pool size and passed with the fix disabled. -
Stream ops targeting a
[dj-virtual]container now go through its item pool (#2017 item 1). A virtual container's children are only the visible window; the full collection lives instate.itemswith off-window rows detached.17-streaming.jshad zerodj-virtualawareness and mutated the element directly, so the pairing documented inlarge-lists.md— stream the data, virtualize the rendering — did not work without bespoke app JS.append/prependnow splice into the pool andprunetrims the pool, each falling back to the original direct-DOM path when the target is not a virtual list (a regression test pins that ordinary stream targets are untouched). Honest scope, established by gate-off rather than assumed: append already worked — the loose-child absorb (#1989 symptom 2) pulls an appended node into the pool at the tail, which is correct for an append. The first version of these tests counted pool SIZE only and so passed with the routing disabled — tautological; rewritten to assert order, which exposed what is genuinely broken: prepend, since absorb always appends at the tail (the append-only assumption tracked as #2017 item 4), and prune, since trimming.childrenprunes the visible window rather than the pool. The append case is kept and labelled as characterization. Also corrects an earlier claim of mine that this item was blocked on #2112 — that concerned the SYNC op path (_stream_operations, which has no consumer); the live ASYNC path (stream_to→_send_stream_ops→handleStreamMessage) is the one that matters and was never blocked. 5 cases intests/js/stream-ops-dj-virtual-2017.test.js; gate-off verified (#1468): 2 of 5 fail without the routing. Bundle: +331 bytes gz. -
Patch misses on an off-window
[dj-virtual]item now name the real cause (#2017 item 5). A virtual list keeps off-window items detached, held only instate.items, so a server patch aimed at one legitimately resolves tonull. The existing miss warning then listed generic causes — third-party JS, a changed{% if %}, a different rendering path — every one of which is wrong in this case; following them is what made #1988/#1989 expensive to investigate. Newwindow.djust._findVirtualListHolding(djId)re-discovers[dj-virtual]containers from the DOM (STATEis a WeakMap and deliberately not iterable, #2033) and returns the one holding a detached item with that dj-id; the detached check is what makes it false-positive-free, since an item still in the document was resolvable and the miss has another cause. The VDOM patcher calls it on the miss path and, only on a positive identification, names the container and explains the patch cannot land until the item scrolls back into the window. Deliberately unconditional rather thandjustDebug-gated (which is what the issue asked for): the helper answers only when it can prove a detached holder, so there is no speculative branch to add noise, and a developer hitting this in production is exactly who needs it. Scope: item 5 only. Items 2–4 (Rust differdj-virtualawareness, out-of-window patch landing, keyed insert position) need an ADR —grep -rn "dj-virtual" crates/confirms zero awareness in the differ — and item 1 is blocked because it presumes stream-ops reach the client, which they do not. The lookup is scoped to the root the patch resolved against, because dj-id namespaces are per-view — a document-wide scan would let a sticky child's detached item be blamed for a parent-root miss, which is the exact misdirection the feature exists to remove — and it is throttled, since the expensive case (id not held → full scan ofstate.items, no early exit) is also the common one: a burst of 200 misses against a 10k-item list costs 616 ms unthrottled and nothing measurable with the throttle stamped before the lookup rather than on a hit. 10 cases intests/js/dj-virtual-patch-miss-diagnostic-2017.test.js(helper exported, positive identification, unknown id, no-virtual-list page, end-to-end miss warning, no-false-positive guard, ATTACHED-item guard, cross-root scoping, root-IS-container, and a throttle pin asserted as an ordering invariant rather than a duration so it cannot flake). Gate-off verified (#1468) per addition: disabling the patcher hint fails 1, removing the helper export fails 5, dropping thedocument.containsguard fails 1, dropping root-scoping fails 1. Bundle: +373 bytes gz. -
Official adapters —
dj-chartpilot, "user brings the library" (#2063, ADR-025 milestone C). ADR-025 shipped the two extension sockets (JS.ext.*custom commands #2051,dj-hooktyped values/targets #2052) and explicitly deferred the adapters that would ride on them; this is the first one. A one-file, no-build adapter lives in the wheel atpython/djust/static/djust/ext/dj-chart.jsand is enabled withDJUST_CONFIG = {"extensions": ["chart"]}— djust ships only the morph-safe glue (a pre-writtendj-hookplus pre-registeredJS.extcommandschart_update/chart_set_data); Chart.js itself is never bundled, downloaded, or vendored.mounted()builds the chart from ADR-025 typed values (dj-hook-value-type/-data/-options);updated()mutates the existing instance and callsupdate()rather than destroy-and-rebuild, which is precisely the #1724 teardown class the adapter exists to prevent;destroyed()destroys exactly once (WeakMap + nulled ref, so a double lifecycle dispatch cannot double-destroy). A missing library degrades to one clearconsole.errorper element — not per re-render, sinceupdated()retries the mount and would otherwise flood the console — and never throws, so other hooks on the page still mount. Not bundled intoclient.js: the shared ~87 KB gz budget is unchanged for anyone who doesn't opt in (pinned by a test assertingclient.jscontains no adapter code); the adapter is 2.3 KB gz, fetched only when enabled.python/djust/extensions.pyis the single source of truth for which adapters exist, so the script injector and the system check resolve through one table and cannot drift (#1646). Injection appends the adapter tag after the client tag in_inject_client_script—deferpreserves document order, which is the whole reasonwindow.djust.commands.registeris reachable by the time the adapter runs; nothing at all is emitted when the list is empty or absent (#246 zero-cost-when-unused). New system checkdjust.C015reports an unknown adapter name (and a non-listextensionsvalue) at startup, because an unrecognized name is otherwise a completely silent no-op — no script, no error, a hook that simply never mounts; dogfooded against the demo project with 0 false positives and an empirical canary confirming it fires on a typo (#1459/#1060). Scope is deliberately ONE adapter (pinned bytest_chart_is_the_only_shipped_adapter) — each is an ongoing maintenance commitment, sodj-sortable/dj-editorwait for demand. Self-review caught and corrected a wrong recipe before merge: the initial header told users to adddj-update="ignore"to the canvas, which would have broken the feature — the patcher returns early on that attribute before attribute sync, so the server could never updatedj-hook-value-dataand the chart would show its first dataset forever; it is also unnecessary, since a<canvas>has no server-owned children and the morph already refuses to remove a canvas'swidth/height. Tests: 17 cases intests/unit/test_extensions_2063.py(resolution incl. unknown-name-dropped and non-list config, injection presence/order/defer, C015 incl. suppression, plus two pins that fire C015 through the REALcheck_configuration()aggregate — without them the production call site could be deleted with the whole suite still green, the decorative-pin class #1859) + 10 cases intests/js/dj_chart_adapter.test.js, driven through the REAL hook dispatcher (djust.mountHooks/updateHooks/destroyAllHooks) and the realjs._executeOpsdispatch rather than hand-built instances (#1196) — the first draft hand-rolled an instance and silently double-mounted, which is exactly the wiring a method-level test cannot see. Gate-off verified (#1468): neutering the injection loop fails 3 of the injection tests. Docs: newdocs/website/guides/official-adapters.md, linked from both_config.yamlandindex.md. -
Machine-readable surface manifest + parity canaries — phase 1 of #2064 (djust.org's
/docs/directives/silently lacked the entire JS-commands family and both new hook attributes for months).djust.schema.get_surface_manifest()returns a JSON-serializable, deterministically-sorted dict (manifest_version,djust_version,directives,js_commands,view_api) covering everydj-*template directive, everydjust.js.JSchain command (DERIVED viadir(JS)introspection, not hand-listed), and the public LiveView API (reused fromget_framework_schema()'s sections, not reinvented). Works without Django setup, same framework-only contract asget_framework_schema(). New management commanddjust_surface_manifest(mirrorsdjust_schema's shape) prints it as JSON,--indentoptional. Phase 2 (djust.org CI diffing its reference-doc fixture against this manifest) is tracked separately, out of this repo. The load-bearing part is the parity canaries intest_surface_manifest_2064.py(TestJSCommandTriParity,TestDirectiveBindingParity,TestEqualityShapeCoverage,TestManifestShape,TestGateOffNonVacuous) — a manifest that can silently drift is decorative (#1859).TestJSCommandTriParitystructurally extracts the client chain-factory method names fromstatic/djust/src/26-js-commands.jsand asserts client == Pythondjust.js.JS== manifest;TestDirectiveBindingParityextracts everydj-*attribute the client binds via one of the covered call shapes across all 55static/djust/src/*.jsmodules (regexes anchored to real DOM-binding call shapes: selector arguments,get/has/set/removeAttribute,startsWithprefix checks, attribute-name constants, and strict-equalityname === 'dj-X'/m.attributeName === 'dj-X'reads — the last being the sole observation trigger for the MutationObserver modules, pinned byTestEqualityShapeCoverage) and asserts each is documented inDIRECTIVESor in the explicit, justified_STRUCTURAL_EXCLUSIONS/_PREFIX_FAMILY_EXCLUSIONSdicts (dj-id,dj-root/dj-view, the sticky-child auto-emitted markers — never user-authored). Dogfooding (#1459) ran both canaries against the pre-existingDIRECTIVES(28 entries) BEFORE any backfill: 57 undocumented directive names + 4 undocumented prefix families (dj-window-,dj-document-,dj-hook-value-,dj-value-) — RED, confirming the canary catches the drift class. Backfilled 37 newDIRECTIVESentries (dj-click-away,dj-mounted,dj-cloak,dj-mutation,dj-transition/dj-remove/dj-transition-group/dj-flip/dj-view-transitions,dj-viewport-top/dj-viewport-bottom,dj-virtual,dj-shortcut,dj-paste,dj-dialog,dj-sticky-scroll,dj-auto-recover,dj-lazy,dj-prefetch,dj-track-static,dj-debounce/dj-throttle,dj-loading/dj-loading.for, and more — each description sourced from reading the corresponding client module, not invented) plusrelated_attributesondj-copy/dj-hook/dj-inputfor their sibling modifier attributes (dj-hook-value-*/dj-hook-targetondj-hookcloses the exact #2064-cited gap).TestGateOffNonVacuousproves both canaries are load-bearing: removing a realDIRECTIVESentry, prefix family, or Python JS method from the in-memory comparison makes the coverage check correctly report the gap (also verified on-disk: temporarily removingdj-click-awayfromschema.pyandpushfromjs.pyand re-running the suite both produced real RED, restored after);TestEqualityShapeCoverageadds an empirical canary (#1459) that injects an equality-onlyname === 'dj-probeonly'binding and proves the extraction catches it only while thename === 'dj-X'regex is wired in. -
Stop committing
.min.js.gz/.min.js.br/.min.js.mapbuild siblings — kill cross-toolchain churn (#2054).client.min.js.gz/.br/.mapanddebug-panel.min.js.gz/.br/.mapwere tracked in git; every localscripts/build-client.shrun (via thebuild-jspre-commit hook ormake build-js) touched them on disk, and a broadgit add/git commit -aswept the diff into unrelated PRs even when the source and.min.jsitself were byte-identical (PR #2051's commit31354b91carried +998 B/-30 B ofdebug-panel.min.js.br/.gzdiff with zero debug-panel source changes). Root-caused two distinct drift sources: (1)gzip/brotliare unpinned system CLI tools (unliketerser, which is npm-pinned viapackage-lock.json), so their compressed output isn't guaranteed byte-identical across contributor machines even for identical input; (2).mapfiles are always non-reproducible — terser'ssourcesfield embeds the absolute filesystem path of the checkout, verified to differ on every distinct clone/worktree (reproduced locally: re-running the build with an unchanged source showed.mapdiffs on the very same machine, purely from the worktree path changing). Consumer audit found no runtime or CI reader of the committed files: djust's own{% static %}injection only ever referencesclient.js/client.min.js(never the compressed siblings); C013's stale-bundle check hashes onlyclient.min.js; the documented WhiteNoise deployment pattern (whitenoise.storage.CompressedManifestStaticFilesStorage, as configured in djust.org's ownsettings.py) regenerates its own.gz/.bratcollectstatictime regardless, superseding anything shipped in the wheel;scripts/check-doc-snippets.py's JS-size doc-claim check already had a graceful "bundle not found → warn and skip" path built in for exactly this "fresh checkout before build ran" scenario. The.gz/.br/.mapfiles are now gitignored, build-time-only artifacts (scripts/build-client.shstill generates them locally formake start/local WhiteNoise testing) —client.min.js/debug-panel.min.jsthemselves stay committed unchanged, since terser's pinned-toolchain output is genuinely deterministic.tests/unit/test_client_minified.py::test_gzip_sibling_exists_after_buildnow skips gracefully (instead of hard-failing) when the artifact is absent, matching the existingclient.min.js-missing/no-terser skip pattern; verified the skip path fires correctly with the artifact removed (non-tautological per #1468). Wheel impact:client.min.js.gz/.br/.mapand the debug-panel equivalents are no longer packaged in released wheels (the release workflow does not rebuild JS assets at packaging time; it only ships what's checked out at the tag).client.min.js/debug-panel.min.js— the files actually served — are unaffected. -
The per-item loop render+parse cache is now default-ON (#2062).
LIVEVIEW_CONFIG['loop_render_cache_enabled'](#1967/#1969/#1970) graduated from its split-foundation default-OFF soak (flag-gated since v1.1.0rc5, byte-identity ON==OFF proven across the template matrix): a pure reorder of a large keyed list is now O(changed) re-renders out of the box (measured 15–22%render_with_diffwins on reorder benchmarks). Opt out withLIVEVIEW_CONFIG = {"loop_render_cache_enabled": False}— the flag is now a kill-switch. Only position-independent loop bodies reading nothing but the loop variable are cached (bodies using{% if %}/{% cycle %}/nested loops/forloop/outer-context reads are auto-excluded, correct by construction), so no template changes are required. Preconditions honored at flip time: the #2067 cross-loop keyspace fix landed first, and the full three-root suite ran green under the new default — includingtest_template_auto_call_1985.py, the test whose failure blocked (and rightly so) the first flip attempt before #2067 was found. The wire (RustBridgeMixin._apply_loop_render_cache_flag) carries the default onto everyRustLiveView; the Rust-side constructor default stays conservative-OFF so the config wire is the single source of the ON default (#1646). Tests:python/djust/tests/test_loop_cache_default_on_2062.py(wire default-ON pin, behavioral reorder-hits pin, explicit-False opt-out kill-switch — which doubles as the gate-off sibling (#1468) — and the bare-RustLiveView pre-wire OFF pin); the config-default pin intest_loop_render_cache_1967.pyflipped totest_config_default_is_truewith provenance.
Fixed
-
The client-size sweep is finished, and "guarded" is now a checked property rather than an act of diligence (#2148, completing #2138). #2147 corrected 10 claims and guarded 9 files and said plainly it had not finished; this finishes it. 24 corrections across 14 files: 12 in
examples/demo_project/(the issue named 6 — the sweep also founddjust_demos/× 3 and the second copy ofstatus_change_djust.html, since that template exists in two source trees),ROADMAP.md's forward-looking WASM entry (~87 KB gzipped raw / ~37 KB minified target, both stale), anddocs/state-management/IMPLEMENTATION_PHASE2.md. Every figure said~5KBfor a client that ships at 58.7 KB gz — wrong by ~11×, and stale since PR #796/#800 perRETRO.md:50, i.e. copied forward across four releases. The corrections are written as~58 KB gzdeliberately, not as a house-style choice:gzis in the checker's per-line context gate, so writing the figure that way makes the corrected line visible to the check. Several claims previously sat alone on a line with their label on the neighbouring one (<div class="hero-stat-value">~5KB</div>), which a per-line checker structurally cannot see — adding those files to the checked set without also fixing the wording would have been a decorative guard (#1859). Two animated counters (data-count="5" data-suffix="KB") are corrected but remain unguarded: the claim regex requires a~, and adding one would break the count-up widget.IMPLEMENTATION_PHASE2.mdis deliberately NOT renumbered. It is a dated plan ("Status: In Progress, Started: 2025-01-12") whose success criterion readsClient bundle size < 10KB (currently ~5KB). That is not a typo to patch — it is a goal that was superseded at v0.6.0 by the pre-minified distribution, and restating it as< 62KB (currently ~58.7KB)would claim Phase 2 set a target it never set, which is the #2028 class of error (retro-editing a dated record). Marked<!-- size-claim: historical -->with a dated note pointing atmake sizes. One marker rule._SIZE_ARTIFACT_MARKERSwas a dict matched byte-for-byte, so 5 of 6 plausible spellings (<!--size-claim: unminified-->,<!-- SIZE-CLAIM: UNMINIFIED -->, extra spaces) fell back to the default, while_SIZE_HISTORICAL_MARKERwas a bare substring and was tolerant — two families, two rules, for nothing their meanings support. Now one case-insensitive, whitespace-flexible regex. To be accurate about severity: no mis-spelling ever passed silently — an unrecognised artifact marker fell back toshippedand a wrong-artifact claim then failed loudly — so this was ergonomics, not a hole. The gate-off then caught a defect in the fix itself: the first version bounded the regex alternation to the three known values and tested that an invented value "does not suppress", which is tautological — both lookups are membership tests, so an unrecognised value resolves identically whether the alternation is bounded or not, and the test could not go red. The bound is load-bearing instead: an unknown value is now reported at its file:line, so<!-- size-claim: histrical -->is named rather than silently governing nothing — which is precisely the failure #2138 found twice inCLAUDE.md. The two build guards no longer mask each other.write_size_manifest's missing-.gzskip and its zero-measurement refusal both leave the manifest untouched, so the single test asserting "the manifest was preserved" stayed green when either was deleted and went red only when both were. Measured on the real script: with the skip removed, the missing-.gzcase still ends at rc 1 via the refusal; with the refusal removed, a present.gzplus an emptysrc/tree writes"modules": 0. Each guard now has a test asserting the signal only it produces (exit code plus message) on the scenario only it reaches. The part that retires the class rather than the instance:test_every_file_with_a_client_size_claim_is_guarded_or_listedscans tracked.md/.txt/.html/.pyfiles and requires every checker-visible client-size figure to be either checked against the measured manifest or listed in_KNOWN_UNGUARDED_SIZE_CLAIMSwith a reason — 13 entries today, in three honest kinds: append-only records (CHANGELOG.md,RETRO.md,docs/archive/) where correcting would falsify history; figures that are not djust's shipped total (a competitor's bundle, a per-feature delta, a brotli artifact the manifest does not name), which the check would reject as wrong because it compares every claim on a matching line against one number; and the checker's own fixtures. A companion test fails when an allowlist entry has nothing left to allow, so a cleaned-up file cannot leave a free exemption behind. This is what would have prevented #2148 existing: "correct the number" was diligence with nothing checking that the file it lived in would ever be looked at again. Left unfixed, deliberately (#1079): six furtherdocs/state-management/design docs carry the same 2025-01 figures, includingSTATE_MANAGEMENT_API.md:1869'sclient.js (current): ~5.0 KB— the most misleading of the lot, since it says current. They are one coherent class needing the same dated treatment, none is published on the docs site (_config.yamllinks only TUTORIAL/PATTERNS/EXAMPLES, which carry no claims), and each needs a judgement about historical-vs-correct; they are listed in the allowlist so they are visible rather than forgotten. Enforcement, stated precisely because #2147's entry was rightly criticised for implying more than it delivered: CI runscheck-doc-snippets.pyunconditionally inside thetest-summarymerge gate, so every newly guarded file is enforced there. The pre-commit hook'sfiles:trigger is not widened to cover them — it would run adjango.setup()-bearing checker on every demo-template commit for marginally earlier feedback — so locally the check fires only when README/QUICKSTART/CLAUDE/pyproject/guides/client-sizes.jsonchange. That gap pre-dates this PR (docs/llms.txtand four others have had it since #2147); this widens the exposure without changing the enforcement point. 24 cases intests/test_client_size_manifest_2138.py(was 15). New cases inTestEachBuildGuardIsIndependentlyLoadBearingandTestOneMarkerSpellingRule, plus the two coverage pins. Gate-off verified per mechanism — all 7 behaviours go red when neutered, each mutation asserting it applied before the run and counting pytest errors as well as failures — and an empirical canary (#1459) confirms all 14 newly guarded files fire on a re-introduced stale figure. -
A blocked push now says whose failures blocked it, and a red
mainis found before someone pushes into it (#2139).maincarried three failing doc-snippet tests until #2134; because the pre-push hook runs the full suite, every contributor's push was rejected — on branches with nothing to do with the failure. Nothing alerted, the failing test names sit under ~40 passing hook lines, and it took three failed pushes to diagnose. The cost is not the lost minutes but that a persistently red suite trains people to read red as noise and reach for--no-verify(the #2124 argument).scripts/pre-push-pytest.shnow wraps the hook: on failure it prints the failing ids prominently, then re-runs only those against the merge-base in a scratch worktree and reports which were already broken — "ALL 1 failure(s) ALSO fail at the merge-base. Your branch did not cause them." It deliberately does not suggest--no-verify; the push stays blocked, and the point is to make the gate legible rather than to open it. Every path that cannot compare says the failures are not attributed rather than implying an answer, because a wrong attribution either dismisses a real regression or blames the wrong person..github/workflows/main-health.ymlruns the suite onmaindaily and opens/updates/closes a tracking issue whose body leads with the blast radius, since the reader most likely to open it is someone whose unrelated push just bounced. Three defects in the first version, each found by running it rather than reading it: it copied one.soviahead -1where the tree carries one per Python ABI, so a mismatched ABI made every import fail at the merge-base and every failure look pre-existing — the wrong answer delivered confidently; it treated a test absent at the merge-base as "run did not complete" and refused to attribute, which is the most common case (a branch that adds a test); and its detection regex required a number beforeno tests ran, which pytest prints without one. Used on itself, it correctly called the one test this branch broke new while leaving a flaky unrelated failure unattributed. 12 cases; gate-off verified per mechanism (8 mutations, each asserting it applied) — including one test that needed rewriting twice before it pinned the real property, sincecount(...) >= 3tolerates losing exactly the path that would then guess silently. -
A blocked push now says whose failures blocked it, and a red
mainis found before someone pushes into it (#2139).maincarried three failing doc-snippet tests until #2134; because the pre-push hook runs the full suite, every contributor's push was rejected — on branches with nothing to do with the failure. Nothing alerted, the failing test names sit under ~40 passing hook lines, and it took three failed pushes to diagnose. The cost is not the lost minutes but that a persistently red suite trains people to read red as noise and reach for--no-verify(the #2124 argument).scripts/pre-push-pytest.shnow wraps the hook: on failure it prints the failing ids prominently, then re-runs only those against the merge-base in a scratch worktree and reports which were already broken — "ALL 1 failure(s) ALSO fail at the merge-base. Your branch did not cause them." It deliberately does not suggest--no-verify; the push stays blocked, and the point is to make the gate legible rather than to open it. Every path that cannot compare says the failures are not attributed rather than implying an answer, because a wrong attribution either dismisses a real regression or blames the wrong person..github/workflows/main-health.ymlruns the suite onmaindaily and opens/updates/closes a tracking issue whose body leads with the blast radius, since the reader most likely to open it is someone whose unrelated push just bounced. The first version shipped four defects, and the fourth is why the other three could. It copied one.soviahead -1where the tree carries one per Python ABI, so a mismatched ABI made every import fail at the merge-base and every failure look pre-existing — the wrong answer delivered confidently. It refused to attribute a test absent at the merge-base, which is the most common case (a branch that adds one). The fix for that was itself the third defect: pytest resolves every argument before collecting, so one absent id aborts the whole session withno tests ranand zero results — and the fix WHITELISTED that string as usable signal, so the merge-base set came back empty and every genuinely pre-existing failure was reported as new on this branch. Reproduced with main red at 2 and the branch adding 1: "all 3 are new on this branch", when two of the three fail on main. It blamed the contributor for main's breakage — in exactly the mixed case the tool exists for — and the behaviour it replaced was correct, bailing with "NOT attributed". Each failing id is now checked in its own invocation, so no single id can poison the others; ids are held in an array, since$FAILEDunquoted split a parametrizedtest_x[a b]into two unresolvable args and poisoned the same run. The fourth defect: all twelve original tests were source-greps, so you could invert the partition — swapping yours and pre-existing — or delete the merge-base run entirely, and the suite stayed green; the module docstring meanwhile claimed they ran against a real worktree "because that is the only way to know it attributes correctly rather than confidently", which read as evidence the empirical check had been done.tests/test_red_main_attribution_behaviour_2139.pynow builds a real git repo with a real merge-base and runs the actual script: branch-local failure, base-present failure, the mixed partition, a renamed test, a space-containing id, and a green run doing no merge-base work. Both killer gate-offs fail 1 where they failed 0. 19 cases. The daily workflow also now reports when it could not run at all, rather than reporting infrastructure failure as a red main and sending people hunting for a regression that does not exist. -
A blocked push now says whose failures blocked it, and a red
mainis found before someone pushes into it (#2139).maincarried three failing doc-snippet tests until #2134; because the pre-push hook runs the full suite, every contributor's push was rejected — on branches with nothing to do with the failure. Nothing alerted, the failing test names sit under ~40 passing hook lines, and it took three failed pushes to diagnose. The cost is not the lost minutes but that a persistently red suite trains people to read red as noise and reach for--no-verify(the #2124 argument).scripts/pre-push-pytest.shnow wraps the hook: on failure it prints the failing ids prominently, then re-runs only those against the merge-base in a scratch worktree and reports which were already broken — "ALL 1 failure(s) ALSO fail at the merge-base. Your branch did not cause them." It deliberately does not suggest--no-verify; the push stays blocked, and the point is to make the gate legible rather than to open it. Every path that cannot compare says the failures are not attributed rather than implying an answer, because a wrong attribution either dismisses a real regression or blames the wrong person..github/workflows/main-health.ymlruns the suite onmaindaily and opens/updates/closes a tracking issue whose body leads with the blast radius, since the reader most likely to open it is someone whose unrelated push just bounced. The first version shipped four defects, and the fourth is why the other three could. It copied one.soviahead -1where the tree carries one per Python ABI, so a mismatched ABI made every import fail at the merge-base and every failure look pre-existing — the wrong answer delivered confidently. It refused to attribute a test absent at the merge-base, which is the most common case (a branch that adds one). The fix for that was itself the third defect: pytest resolves every argument before collecting, so one absent id aborts the whole session withno tests ranand zero results — and the fix WHITELISTED that string as usable signal, so the merge-base set came back empty and every genuinely pre-existing failure was reported as new on this branch. Reproduced with main red at 2 and the branch adding 1: "all 3 are new on this branch", when two of the three fail on main. It blamed the contributor for main's breakage — in exactly the mixed case the tool exists for — and the behaviour it replaced was correct, bailing with "NOT attributed". Each failing id is now checked in its own invocation, so no single id can poison the others; ids are held in an array, since$FAILEDunquoted split a parametrizedtest_x[a b]into two unresolvable args and poisoned the same run. The fourth defect: all twelve original tests were source-greps, so you could invert the partition — swapping yours and pre-existing — or delete the merge-base run entirely, and the suite stayed green; the module docstring meanwhile claimed they ran against a real worktree "because that is the only way to know it attributes correctly rather than confidently", which read as evidence the empirical check had been done.tests/test_red_main_attribution_behaviour_2139.pynow builds a real git repo with a real merge-base and runs the actual script: branch-local failure, base-present failure, the mixed partition, a renamed test, a space-containing id, and a green run doing no merge-base work. Inverting the partition now fails 10 tests and deleting the merge-base run fails 11, where both previously failed 0. A third round found three more defects, two of them introduced by the second round's fixes. Thesed 's/ - [^-]*$//'that fixed ids containing-only substitutes when the failure MESSAGE has no hyphen — false for most real pytest messages (assert 1 == -1,KeyError: 'main-health') — so the id kept the message glued on, was unresolvable at the merge-base, and was announced as new: the same wrong answer reached from the other side, on the very doc-snippet tests #2139 was written for, and with the verdict depending on terminal width since pytest truncates that line to$COLUMNS. Ids are now split at the first-outside brackets, which is exact rather than heuristic because a node id can only contain-inside its[parameters]. The report arms were gated on the pre-existing count rather than on what was actually resolved, so a run that could check nothing printed "all N are new on this branch" and then contradicted itself two lines later. And the merge-base run hardcoded.venv/bin/pythonwhile the main run went throughrun-with-venv-python.sh— parallel-path drift (#1646) inside one file — so inside a linked worktree, where most agent work here happens, every id failed to execute and was then blamed on the pusher. An id that is absent at the merge-base is now confirmed collectible in the current tree before being called new, since a mis-parsed id is otherwise indistinguishable from a new test. The daily workflow reports when it could not run at all rather than calling an infrastructure failure a red main; it no longer swallows a failed Rust build, treats a missing pytest summary as could not tell, skips cancelled runs, paginates the issue lookup, ignores pull requests sharing the title, and closes whichever of its two issues has gone stale — previously a "could not run" issue could never be closed, because the title it searched for was computed from the current run's state. 59 cases across three files, including a harness that executes the workflow's JavaScript under stubs; 16 of 17 gate-off mutations fail at least one test. -
A blocked push now says whose failures blocked it, and a red
mainis found before someone pushes into it (#2139).maincarried three failing doc-snippet tests until #2134; because the pre-push hook runs the full suite, every contributor's push was rejected — on branches with nothing to do with the failure. Nothing alerted, the failing test names sit under ~40 passing hook lines, and it took three failed pushes to diagnose. The cost is not the lost minutes but that a persistently red suite trains people to read red as noise and reach for--no-verify(the #2124 argument).scripts/pre-push-pytest.shnow wraps the hook: on failure it prints the failing ids prominently, then re-runs only those against the merge-base in a scratch worktree and reports which were already broken — "ALL 1 failure(s) ALSO fail at the merge-base. Your branch did not cause them." It deliberately does not suggest--no-verify; the push stays blocked, and the point is to make the gate legible rather than to open it. Every path that cannot compare says the failures are not attributed rather than implying an answer, because a wrong attribution either dismisses a real regression or blames the wrong person..github/workflows/main-health.ymlruns the suite onmaindaily and opens/updates/closes a tracking issue whose body leads with the blast radius, since the reader most likely to open it is someone whose unrelated push just bounced. The first version shipped four defects, and the fourth is why the other three could. It copied one.soviahead -1where the tree carries one per Python ABI, so a mismatched ABI made every import fail at the merge-base and every failure look pre-existing — the wrong answer delivered confidently. It refused to attribute a test absent at the merge-base, which is the most common case (a branch that adds one). The fix for that was itself the third defect: pytest resolves every argument before collecting, so one absent id aborts the whole session withno tests ranand zero results — and the fix WHITELISTED that string as usable signal, so the merge-base set came back empty and every genuinely pre-existing failure was reported as new on this branch. Reproduced with main red at 2 and the branch adding 1: "all 3 are new on this branch", when two of the three fail on main. It blamed the contributor for main's breakage — in exactly the mixed case the tool exists for — and the behaviour it replaced was correct, bailing with "NOT attributed". Each failing id is now checked in its own invocation, so no single id can poison the others; ids are held in an array, since$FAILEDunquoted split a parametrizedtest_x[a b]into two unresolvable args and poisoned the same run. The fourth defect: all twelve original tests were source-greps, so you could invert the partition — swapping yours and pre-existing — or delete the merge-base run entirely, and the suite stayed green; the module docstring meanwhile claimed they ran against a real worktree "because that is the only way to know it attributes correctly rather than confidently", which read as evidence the empirical check had been done.tests/test_red_main_attribution_behaviour_2139.pynow builds a real git repo with a real merge-base and runs the actual script: branch-local failure, base-present failure, the mixed partition, a renamed test, a space-containing id, and a green run doing no merge-base work. Inverting the partition now fails 10 tests and deleting the merge-base run fails 11, where both previously failed 0. A third round found three more defects, two of them introduced by the second round's fixes. Thesed 's/ - [^-]*$//'that fixed ids containing-only substitutes when the failure MESSAGE has no hyphen — false for most real pytest messages (assert 1 == -1,KeyError: 'main-health') — so the id kept the message glued on, was unresolvable at the merge-base, and was announced as new: the same wrong answer reached from the other side, on the very doc-snippet tests #2139 was written for, and with the verdict depending on terminal width since pytest truncates that line to$COLUMNS. Ids are now split at the first-outside brackets, which is exact rather than heuristic because a node id can only contain-inside its[parameters]. The report arms were gated on the pre-existing count rather than on what was actually resolved, so a run that could check nothing printed "all N are new on this branch" and then contradicted itself two lines later. And the merge-base run hardcoded.venv/bin/pythonwhile the main run went throughrun-with-venv-python.sh— parallel-path drift (#1646) inside one file — so inside a linked worktree, where most agent work here happens, every id failed to execute and was then blamed on the pusher. An id that is absent at the merge-base is now confirmed collectible in the current tree before being called new, since a mis-parsed id is otherwise indistinguishable from a new test. The daily workflow reports when it could not run at all rather than calling an infrastructure failure a red main; it no longer swallows a failed Rust build, treats a missing pytest summary as could not tell, skips cancelled runs, paginates the issue lookup, ignores pull requests sharing the title, and closes whichever of its two issues has gone stale — previously a "could not run" issue could never be closed, because the title it searched for was computed from the current run's state. A fourth round found three more, two of them again caused by the previous round's fixes. The behavioural test file hardcodedROOT/.venv/bin/python— the exact path the script had just stopped hardcoding — so in a linked worktree, where most agent work here happens, 19 of its cases silently skipped, including the one whose whole purpose is the worktree case; any gate-off measured there read 0 and looked like evidence. The "all pre-existing" arm then issued its global verdict ("your branch did not cause them — main is red") unconditionally, so with one failure unresolved alongside one pre-existing, a pusher whose own regression was the unchecked one was told to go wait for someone else; the global sentences are now gated on nothing being unresolved or capped, and the arm lists the ids it is making a claim about. And classification grepped the merge-base output text, wherenot foundis not exclusive to "absent" —pytest.fail("config file not found")matched, and there was no arm at all for a test that ERRORED rather than failed, so a test broken in both trees was announced as new. That is now decided by pytest's exit code (0 passed → new, 1 failed-or-errored → pre-existing, 4/5 unresolvable → absent, 2/3 → cannot tell), changing the shape rather than adding a fourth string, since adding strings is what produced the bug. New cases inTestExitCodeClassificationand the workflow-JS harness; the gate-off baseline is asserted clean-green before any mutation is trusted, after a harness whose baseline silently collected nothing made 17 rows read 0. -
Client-size figures are generated, not asserted (#2138). Three places in prose quoted a client size describing two different artifacts, and only the README pair was checked — so the unchecked pair had drifted more than 2× (
~87 KBagainst an actual~188 KB) precisely because nothing looked at it, while the checked pair drifted across #2114/#2115/#2120, none crossing the ±3 KB band alone. The result was three failing tests onmainthat made the pre-push hook reject every branch until #2133 landed. Adding a second checker would be the same failure mode one step later, so:scripts/build-client.shnow writesclient-sizes.json, the single measured source naming both artifacts, and it is committed — the.gzfiles are gitignored, so without a checked-in manifest the check measures a locally-built artifact and warns-and-skips on a fresh clone, meaning CI and a contributor could disagree about whether a claim is in band.check-doc-snippets.pyresolves the artifact per line rather than per file (a doc legitimately cites both, so a per-file mapping cannot work) and now readsCLAUDE.mdtoo, with an opt-in<!-- size-claim: historical -->marker because prose sometimes quotes a figure precisely to say it was wrong. The prose got more accurate as a side effect, which is the real win: README's architecture diagram saidclient.js (~58 KB gz)when the shipped artifact isclient.min.js.gzandclient.jsis 188 KB — mislabelled, not merely stale.make sizesprints the current figures so updating prose is mechanical. 15 cases intests/test_client_size_manifest_2138.py, covering that the manifest is tracked, that it matches the files on disk (a stale manifest would authorise a wrong claim), and that 58 KB resolves as correct for the shipped bundle and wrong for the unminified input on adjacent lines. The first version of this fix reproduced the bug it exists to remove, twice over, and the Stage 11 review caught both. The build wrote a zeroed manifest when run without terser — a path the script explicitly documents as supported — becausemin_gzdefaulted to 0 and thegzipcall was unguarded while the identical call 80 lines above guards oncommand -v. Exit 0, no warning, valid JSON; the checker then trusted it, computed a band of[-3, +3] KB, and rejected every correct claim in README and CLAUDE.md. A strictly worse #2133 — four blocked claims instead of three, reachable by running the commandmake sizestells you to run, with emitted advice that would have had a contributor silence four correct figures. It now skips rather than zeroes, guardsgzip, and hard-fails on any zero measurement. And the per-line resolver could never resolveunminified: "minified" is a substring of it, and last-position-wins meant the shipped mapping always won — dead config. It mis-resolved realistic prose in both directions, and the real docs passed only by accident of word order. Replaced with explicit<!-- size-claim: unminified -->markers, default shipped, because prose is not a reliable place to infer intent from. A marker governs its own line, and both markers inCLAUDE.mdhad landed on the wrap, governing nothing — gate-off proved the historical one decorative; the block is reflowed so every number shares a line with its marker. Third: the check gated on the word "gz", so it could not see~5KB client runtimeinREADME.md:91— wrong by ~11× against the 58.7 KB it actually injects — nor eight more acrossdocs/. The most-wrong claims in the repo were the ones the check could not see, which is exactly the failure mode #2138 exists to retire. The gate now matches lines about the client; 10 claims across 9 files are corrected (9 ×~5KBplus a stale~63KBinTEMPLATE_BACKEND.md), and it is pinned in both directions — the~5KBshape is caught, while panel memory, page weight and a Tailwind fixture are not. Every corrected file is now in the checked set, so it cannot drift back; correcting a claim without guarding its file is the same "N copies, only some checked" class at smaller scale. Not complete, deliberately: roughly 19 further~5KBcopies remain, almost all inexamples/demo_project/rendered demo copy, plusdocs/state-management/IMPLEMENTATION_PHASE2.mdand a stale~87 KBinROADMAP.md— filed as #2148 rather than widening this diff a third time. An earlier draft of this entry said "five more copies" and "six claims" and implied the sweep was complete; both counts were wrong and the completeness claim was false, on a PR whose subject is prose figures drifting because nobody counted them. Also fixed: the pre-commitbuild-jshook never staged the manifest, so every JS-touching commit left it drifting;check-doc-snippetsdid not fire onCLAUDE.md; and the on-disk assertion compares KB within tolerance rather than exact gz bytes, sincebuild-client.shgitignores.gzprecisely because gzip output varies across toolchains. Empirical canary (#1459) on both drift classes. 15 cases; gate-off verified per mechanism. -
[dj-virtual]content updates are key-addressed, so they land on the right row — and on off-window rows at all (#2136).reconcile_virtual_keyedrecursed into surviving rows with a child path built from the item's absolute index. For a windowed container that index is meaningless — it counts items while the DOM holds only the visible window — and the emitted patch carries no dj-id (text nodes have none), so it resolved purely positionally. Measured against a real mounted list: editing rowk0after a scroll silently rewrotek7, withapplyPatchesreturningtrueand no warning; an off-window row's update could not land at all. NewVirtualUpdate { path, d, key, patches }diffs the row against an empty base path, so the inner patches are relative to the row's own root, and the client resolves the row by key in the item pool before applying them — which is also what makes #2017 item 3 work, since an off-window row is detached, held only instate.items, unreachable by path and findable by key. ADR-026 Option A sketched anUpdate(key, …)op for exactly this; iterations 1 and 2 shipped the structural ops only. The test that pinned the absolute-index arithmetic is rewritten rather than deleted — that arithmetic is what this makes unnecessary — and now asserts no path-addressed content op is emitted for a virtual parent at all, keeping thedj-ifboundary fixture that used to be the diverging shape so that it must now simply not matter. One line was removed for being unpinnable: the patcher arm originally marked the list dirty, and gate-off failed 0 twice — an inner patch mutates the row node, so an attached row already shows the change; an off-window row is re-read from the pool on scroll; and a variable-height change is already caught by the ResizeObserver. The variable-height test written to justify it did not discriminate either (JSDOM does no layout, so measured heights are all 0), so rather than ship an unpinned line with a plausible comment it is gone, with a note to add the mark and the test together if a real case appears. A second defect in the same op, found by the cross-language differential and invisible to both suites: a content diff emitsReplace { path: [] }when a row changes tag, and that op targets the row itself, so it cannot be applied by mutating the row. The client's generic arm reached fornode.parentNode— off-window (detached) that threw; in-window it succeeded against the shell while the pool kept the old node, soapplyPatchesreturnedtruewith no warning and the change reverted the moment the row scrolled out and back, sincerender()re-appends the pool node. The Rust simulator had always handled it correctly (*target = node.clone()), so the two halves of one op disagreed and 26 green Rust binaries plus 23 green JS cases could not see it — only replaying real differ output through the real client did (16/18 exact; both failures were this, in-window and off-window). Fixed by swapping the pool entry via a_virtualReplaceByKeyseam, mirroring what the simulator already did, with later inner patches applying to the new row. Inner-patch coverage had tested exactly one shape (SetText path:[0], d:null) and the uncovered variant is the one that broke (#1543 again); it now coversSetAttron the row itself,RemoveAttr,InsertChild,Replacein-window and off-window,Replace-then-patch, and survival across a scroll. ThequerySelectortrap is real but dodged —scope.querySelectornever matches the scope element, so a row targeting itself by dj-id would miss, and it works only because of thepath.length === 0early return, which nothing pinned until now.VirtualUpdateis also added to the wire-protocol snapshot suite: it is the first recursivePatchpayload, which makes the pin more valuable. 22 Rust cases + 31 JS; gate-off verified per guard (client arm removed 11, row lookup ignoring the key 6, root-Replacenot special-cased 4, pool swap writing the wrong slot 4, differ back to path-addressed 2). Three test-design corrections: the scroll test targetedk0, which the key-ignoring gate-off also returns, so it stayed green under the very gate meant to catch it; the "re-renders" test cannot pin a re-render for the mutating path, since the shell child is the pool node; and the two lines that make the pool-swap work (invalidateWindow+DIRTY.add) had no test at all — gating them off failed 0 of 30 cases and 18 of 18 differential cases, while an in-window row that changes tag would simply never appear. That is the same shape this entry criticises elsewhere, in the branch added to fix it. Also corrected a claim of mine that the review falsified: "JSDOM does no layout, so measured heights are all 0" is false as a general statement — a driven ResizeObserver stub discriminates cleanly (#1830), and is how the re-render got pinned. It is true that no such case exists for the content path, but "JSDOM can't" was the wrong reason. Bundle: +275 bytes gzipped (gzip -n -9; an earlier figure of +276 came from Python'sgzip.compress, which embeds an mtime). -
FormMixin.validate_fieldaccepts the key the client actually sends (#2137). The client sends the field name underfield—09-event-binding.jsat all three of its send sites and20-model-binding.jsfordj-model, becausebuildFormEventParams(09-event-binding.js:525) hardcodes that key, sourcing it fromgetFieldName—data-field, then the element'sname, then itsid— while the Python signature tookfield_nameexclusively. (An earlier draft of this entry said "djust mapsdata-*attributes to handler parameters", which is true ofextractTypedParamson the click/poll/mount paths and false of the form-event path; the claim was inherited verbatim fromwizard.py, where it is equally wrong, and both are corrected.) The**kwargsin that signature is what made it silent: the payload matched no named parameter,**kwargsabsorbed it instead of raisingTypeError, and the handler ran on every keystroke doing nothing. No error, no warning; the form looked live and was inert. Reported against 1.1.0rc8, reproduced before fixing. The fix already existed in this repo —WizardMixin.validate_fieldcarries the samefield or field_namecoalesce with a docstring explaining the wire contract — so this lifts the working implementation (#1077) into the two that never got it rather than inventing a third spelling; three implementations of one contract with one correct is the #1646 shape.fieldwins when both are supplied (it is the contract;field_nameis the compatibility alias), matchingWizardMixin's resolution order. Also renamed a local the change itself made shadow a parameter —field = form.fields.get(field_name)rebound the newfieldparameter, caught by mypy as "str has no attribute clean"; latent rather than broken since nothing downstream read it, but a later edit would have got a form field where it expected a name. 11 cases intests/unit/test_validate_field_wire_contract_2137.pyasserting what the caller observes rather than which parameter carried the name, including two structural pins: everyvalidate_fieldin the tree acceptsfield, and each one actually coalesces rather than accepting the parameter and ignoring it.fieldis the first positional parameter deliberately, and this also fixes djust admin:admin_ext/adapters.pyemitsvalidate_field('<name>', value)at 8 sites, where the barevaluetoken arrives as the literal string"value"in positional slot 1 — withfieldfirst that junk lands infield_nameand the real value survives as a keyword, whereas previously it overwrote the value and the admin validated the string"value"on every keystroke. The cost is thatvalidate_field("email", "text")bindsfield_name="text"and leavesvalue=None; no in-repo or in-docs caller uses that shape, and it is pinned so it is a known property rather than a surprise.docs/website/guides/forms.md— the doc the reported override was written from — documentedfield_nameas the wire shape and is corrected, since leaving it would keep producing the same bug against a signature that now merely tolerates it. The root cause is not addressed here and is filed as #2144:**kwargsconverts a param-name mismatch into silence, and every handler with**kwargs(the documented, recommended shape) has the same exposure —validate_handler_paramsalready computes the unexpected-key set and discards it whenhas_var_keyword. 11 cases. Gate-off verified per guard, every mutation asserting it applied:fieldparam dropped 9,FormMixincoalesce removed 6, resolution order flipped 3, signature reordered 2,admin_extcoalesce removed 1. Two of those replaced a decorative source-grep pin the review falsified in both directions — sabotaging the admin method while keeping the pinned literal passed (0 failures), and a behaviour-preserving ternary refactor failed (2); with behavioural coverage those read 1 and 0. -
Server-side stream deletion now uses the same identity the dom_id does (#2129). The #2121 convergence reached the three dom_id emission sites but not
Stream.delete(), which filtereditemsviaresolve_id/_identityand never consulted the factory — so the emitted op could name a dom_id the client can match while the item stayed on the server, and per #2121's scope note the server-side list is what actually drives rendered output today. Reproducing the filed table first showed two independent causes, not one. (1) The two resolvers disagreed for items the other could handle:resolve_idreturned.idwhenever the attribute existed, without_identity'sis not Nonediscipline, so a row withid=None, pk=5resolved toNoneon one side and5on the other, and an unsaved row (bothNone) toNoneversus its object address —delete()compares one against the other, so neither was ever removable, with the default factory and no custom factory involved.resolve_idnow routes items through_identityvia the_looks_like_itempredicate, so they cannot diverge; a bare id is still the id itself. (2) Nothing consulted a custom factory, though a factory reading content (lambda m: m["slug"]) is the only thing that can identify id-less rows — which is the canonical reason to supply one. Matching is on either identity rather than switching wholesale to the factory, because a caller may still pass a bare id to a custom-factory stream and the factory cannot be applied to an id; switching outright would have stopped those deletes working, a regression traded for a fix. That choice is empirically load-bearing rather than defensive — see the gate-off table below. The first version of this fix was worse than the bug, and the Stage 11 review caught it: it reuseddom_id_for— a string formatter — as a per-item comparator, so any two values whosestr()matched collapsed into one row and deleting one destroyed the other. Rows keyed5and"5", or aUUIDand its own string form (ordinary Django, aUUIDFieldpk beside the string an event-handler param arrives as), were both deleted wheremaincorrectly kept one. Silent and irreversible. The comparison moved to the factory's raw output (the predicate went through one more revision — see below), the factory arm is gated on_looks_like_item(a bare id never goes through the factory, so comparing its output against one compares different things), the target key is hoisted out of the loop (2001 → 1001 factory calls for one delete on a 1000-item stream), and the per-item scan no longer warns — the fallback's traceback-bearing WARNING fired once per unmatched row, so a single delete could emit thousands inside an event handler. The tests assert what the caller observes rather than an internal id. Six assert the other failure direction — that a non-target survives — which the first version had none of: every test asked "did the target get deleted?", and deletion has two failure directions (#1543). One of those six was itself too weak and gate-off found it: the bare-id test passed with the guard removed because the factory raises on an int, hiding the gap; replaced with a tolerant factory that does fail. Two further rounds of the same class then surfaced, and the second one is the reason this entry is long. Agetattr(m, "code", DEFAULT)-style factory gives every keyless row the SAME key, and the fix forDEFAULT=Nonecovered exactly one spelling of nine —"",[],{},(),0,False, a shared string, a shared object and an__eq__-always-True value all destroyed three rows for one targeted, wheremaincorrectly deleted one. Value-by-value patching was not converging, so the fix became an invariant: a delete op names one dom_id, and a dom_id addresses at most one element, so the factory arm may remove at most one row. Ambiguity means "cannot tell which row you meant" — fall back to identity alone, which is what the caller had before custom factories existed. That subsumes theNonespecial-case and is strictly better than it: when exactly one row carries aNonekey, the argument and that row genuinely do name the same dom_id, and the delete now lands where the guard refused it. Single-pass, so one delete over 1000 items is 1001 factory calls. The comparison also moved inside thetry— a value whose__eq__or__str__raises was propagating out ofstream_deleteand aborting the handler. The predicate iskey == target_key and f"{key}" == f"{target_key}"— same value and same rendered dom_id, both halves independently pinned. An interimtype(a) is type(b)guard was wrong in the other direction: it rejected anIntEnumrow against a plain-int argument and aSafeStringagainst astr, both of which emit an identical dom_id, so the client would match and the server keep the row. A fourth round found one axis out: the bound governs the factory arm, but a delete isidentity ∪ factory, so the total was (identity matches) + (0 or 1). With a perfectly unique factory and no collision — rows{id:1,slug:alpha}and{id:2,slug:beta}, deleting{id:1,slug:beta}(row 1 re-read after a rename) — arm 1 took row 1 by id and arm 2 took row 2 by slug: one op, one dom_id, two rows destroyed, wheremaincorrectly removed one. The two arms encode different notions of "same row", and applying both when they disagree is the one choice wrong under either reading. Identity wins, because the factory arm exists to identify rows identity cannot; the inverse would break the legitimate stale-row delete where the stream holds an old copy and the caller passes the updated item (both directions pinned). An ambiguous key now also warns rather than declining silently — two rows sharing a dom_id is an app bug, the client cannot address them separately either, and this fires once per delete, not once per row. 39 cases, parametrized over all ten factory return values rather than adding a tenth special case, and each now deletes twice — by the item (identity resolves it) and by a look-alike (the bound decides) — because the identity-first fix made the first argument shadow the second, so gating off the bound had stopped failing anything. Gate-off, with every mutation asserting it applied before the run — the previous table reported one gate-off as "0 failed" when the mutation had silently not matched and the file was unchanged; it fails 4. A gate-off that does not gate proves nothing, which is a tautological test one level up.resolve_idreverted 5, no factory arm 7, identity arm removed 20 (+8 siblings, the only one that disturbs them), factory arm ungated for bare ids 1, at-most-one bound removed 10, formatted-string comparison 3,f-string half dropped 1,==half dropped 3, comparison outside thetry4, identity-first bound 1, ambiguity warning 1 — and the harness now counts pytest errors, not just failures, and refuses to report a number when a mutation broke the module. One further nit closed: the scan iteratedself.itemslive while calling the user's factory per item, so a factory that appends to the same stream never terminated — a hang inside an event handler, which nothing times out. Snapshotting withlist(...)fixes it; the gate-off is unusual in that reverting it makes the test hang rather than fail, which is precisely why the test earns its place — a hang is invisible to a suite that only counts failures. That trap fired a third time here: one gate-off read as "0 failed" because its mutation produced aSyntaxError, which pytest reports as "1 error". A gate-off that does not gate proves nothing; one that reports zero because it broke the build is worse, because it looks like evidence. -
Binary VDOM diffs are now a named msgpack map, so the bytes can actually be read (#2130). The issue offered three options and all were bad — delete the public API, drop
skip_serializing_iffrom everyPatchvariant (a JSON-shape change every deployed client would see, plus wire-size cost on the live path), or keep it broken and documented. There is a fourth:rmp_serde::to_vec_namedencodes structs as maps rather than positional arrays, which fixes the actual defect in one line at the producer with no JSON change, no API removal and no client-compat event. What the bug was, measured — the issue's framing was close but not exact. The old payload for one appended row decoded to[['InsertChild', [0], '1', 2, [...]]]: valid msgpack, simply not interpretable. Every field is an anonymous slot, so a reader must hard-code the field order of every variant; andskip_serializing_ifon the interiorddrops its slot whenNone, so the same logical field sits at a different index depending on whether an earlier optional happened to be present — Rust's own deserializer rejects it outright, and a hand-written reader would silently readindexwhere it expectedd. Scope checked, not assumed: grep found more producers than the issue cited.SerializableViewState(crates/djust_live/src/lib.rs:1106) has a real deserializer at:1125, so it is a live round-trip rather than a dead path — its only optional isVNode.djust_id, whose #1538 fix is sound only while that field is trailing;cached_htmlis declared after it but carries#[serde(skip)], so the fix still holds, now pinned by a test so a future serialized field added after it fails loudly instead of silently breaking view-state restore.djust_core::to_msgpacktakesdjust_core::Value— djust's own#[serde(untagged)]enum, notserde_json::Valueas an earlier draft of this entry said. It is self-describing, so positional-vs-named does not apply, but for a reason worth knowing: that comes from a hand-writtenDeserializeadded in #612 to fix a msgpack map-read-as-list bug, so that path has its own history with this exact class. Tests read the bytes rather than trusting a Rust round-trip (#1459): 4 cases inpython/djust/tests/test_binary_diff_msgpack_2130.pydrive the real PyO3 producer and decode with the msgpack library a consumer would use, plus 2 inwire_protocol_snapshot.rs(30 total) — one of which keeps the positional encoding's brokenness as the recorded reason the producer usesto_vec_named, so nobody simplifies it back. The first draft of the headline test asserted only thatunpackb()succeeds and passed against the broken encoding, because a positional array is valid msgpack; gate-off caught it and it now asserts each patch decodes to a named map. The trailing-field pin asserts the positional array width, not a round-trip: the round-trip form caught a new field declared plainly afterdjust_idbut not one declared the waydjust_iditself is (#[serde(default, skip_serializing_if = ...)]— the line directly above, and so what a future author would copy), wheredjust_idsilently absorbs the new field's value on the live view-state restore path. Both canaries verified red against realVNode. One caveat the size argument needs: named msgpack is ~30% smaller than JSON raw, but underpermessage-deflatethe repeated field names compress away and the advantage falls to 3–15% — so this fixes a latent trap rather than unlocking much value, and a future implementer should not over-invest on the size case alone. Gate-off verified with a real rebuild rather than an edit: reverting the producer fails 3 of 4 (the 4th is labelled characterization — an empty list encodes identically either way, and it exists because the empty-Vecbranch is a separate call site, #1646). -
The tick loop is now drivable, so its test no longer races a real timer (#2124).
test_tick_interval_view_emits_tick_frame_without_client_eventfailed 2 of 5 runs on an unmodifiedmainand blocked CI on an unrelated PR. Every failing run took ~4.5s and every passing one ~1.7s — the failure correlated with the run being slow, which is a wall-clock race, not a logic bug: under CPU load the first tick render exceeds the 3s sampling window and the frame list comes back empty. That is the class this repo has canonized twice (#1795 concurrency, #1830 rAF), and both prescribe the same remedy — drive the async primitive explicitly and assert the logical invariant; raising the timeout only moves the threshold. The seam had to exist first, so_run_tickis now a four-statement loop (sleep, check the view is alive, call_tick_once, log) with every render/send decision in_tick_once, which a test can call directly. Behaviour is unchanged: every early return in_tick_onceis a SKIP and the one condition that STOPS the loop — no view instance — stays in the caller, so the two are not conflated. Second seam:runtime.maybe_start_tick_task(), because a test asserting "mount starts the tick task" would otherwise have to re-implement the opt-in rule (tick_intervalset ANDhandle_tickoverridden) and would then pass even if the runtime stopped applying it — the decorative-pin failure mode (#1859). The one flaky test becomes four that need no timer, including two gate-off siblings (a newStaticTickViewwhosehandle_tickis a no-op, and the #560 user-event guard) proving the frame comes from a state CHANGE rather than from a tick merely firing. Measured like-for-like on theTestTickAtMountclass: 1.29s idle / 1.45s under 12-way CPU load, flat — versus the old single test's 0.67s idle and 3.5s when loaded-and-failing. Under that same load the old test reproduced 6 of 6, and it also failed 1 of 3 on an idle machine with a cold__pycache__, so it was worse than load-only. A first pass of this shipped the very defect it cites: the mount-path test calledmaybe_start_tick_taskdirectly, so deleting its call site inruntime.pyleft all 9948 tests green while everytick_intervalview silently stopped ticking. The Stage 11 reviewer found it with that exact gate-off. There are now two tests — one for the RULE (which views opt in) and one drivingWSConsumerTransport.on_view_mountedfor the WIRING — plus a contrast sibling asserting a non-opted-in view starts nothing. Gate-off verified (#1468): deleting the call site fails the wiring test; neutering the helper fails both. -
A custom
dom_id=factory now reachesstream_delete, not just insert (#2121).stream(name, items, dom_id=my_fn)lets an app supply its own dom-id factory. Both insert paths called it;stream_deletedid not — it resolved independently throughStream.resolve_id, so a stream created withdom_id=lambda m: m["slug"]inserted rows asrows-hello-worldand deletedrows-1. Nothing can match those two ops up (reproduced before fixing, exactly as filed). Scope, stated honestly:StreamsMixin's ops are not delivered to a transport today —_get_stream_operations()has no callers, and the client's17-streaming.jsspeaksStreamingMixin's separate{op, target, html}protocol, exactly as the#2017 item 1entry above says. So this fixes an internal contract — the op-dict shapeLiveViewTestClientreads, and correctness for whenever the ops are wired — not a live on-screen symptom. An earlier draft of this entry claimed the delete "silently did nothing on screen", which contradicted that neighbouring entry; the Stage 11 review caught it (#1867: a prose invariant has to be checked against the code, not just cited). This is #1646 one level out: PR #2118 made the three sites agree for the default factory, and a custom callable is a third resolution those shared helpers know nothing about — so rather than cure the cited site alone and leave the class alive,Stream.dom_id_for()is now the only place a stream op's dom_id is computed and all three emission sites call it. Two further defects surfaced while converging:Stream.default_dom_idreplaces the closure that lived insidestream()(a closure is a fresh object every call, so nothing could distinguish a default factory from a custom one by identity), and re-callingstream()with a newdom_id=on an existing stream emitted ids from the new callable whilestream_insert/stream_deletekept using the old one — the same disagreement one call later, now cured by making the explicit argument authoritative. A bare id can never produce the custom dom id — the framework cannot invert an arbitrary callable — so it warns and falls back.stream_deleteadditionally tolerates an item the factory rejects ({"id": 1}under am["slug"]factory — a natural shape right after a DB delete, and its parameter is literally nameditem_or_id): the dom id is unrecoverable either way, so raising would convert a cosmetic mismatch into a 500 inside an event handler. That concession is deliberately delete-only. A first pass put it in the shared chokepoint, which silently extended it to both insert paths — where the caller hands over the item that defines the row, so a factory that cannot process it is a programming error. The Stage 11 review measured the consequences: a typo'd factory key madedom_id=silently inert, and a factory raising on only some items left one stream holding ids from two different resolutions — the exact disagreement this entry is about, reachable through its own fix. Insert now raises, pinned by three tests. 20 cases intests/unit/test_stream_custom_dom_id_2121.pyasserting agreement between the emitted ops rather than particular strings, plus a structural pin thatstreams.pynever formats the"{name}-"prefix itself and has exactly 3dom_id_forcall sites. Gate-off verified (#1468) per behaviour, counts measured after the final test set rather than carried over from an earlier one (#1049): revertingstream_deleteto the old resolution fails 8, removing the factory-raises fallback fails 2, removing the rebind fails 2, removing the insert/delete asymmetry fails 3. Those sets are not disjoint — the fallback tests route throughstream_delete, so gate-off A subsumes them. What is disjoint and worth the claim: under gate-off A the nine default-factory and discrimination cases (the #2116-preservation set) all stay green. -
Stream context captures aliased the live item list, so a reset emptied data already handed out (#2119).
_get_streams_context()returnedstream_obj.items— the same list object the stream keeps mutating — and_reset_streams()callsStream.clear(), an in-placeitems.clear(). Anything already holding the context saw its data vanish underneath it, and change-detection could not see the reset either, because its "before" and "after" were the same object. The render stayed correct only because two behaviours cancelled — Action #1039's mutation-after-capture class. Verified before fixing: capture 2 items, call_reset_streams(), capture is 0, and the identity check returns True. Now returns a copy of each item list — shallow on purpose, since the items themselves are the caller's own objects and only the list container needs to be independent. 5 cases intests/unit/test_streams_capture_aliasing_2119.pyfollowing the #1039 shape (mutate the source AFTER capturing, assert the capture is unchanged); gate-off verified (#1468): 4 of 5 fail without the copy. Surfaced by the Stage 11 review of PR #2117. -
Stream.deletesilently could not delete dict items (#2116). Identity was resolved withgetattr(item, "id", getattr(item, "pk", id(item))).getattrreads an attribute, and a dict has none — so a dict item fell through toid(item), the CPython object address, which never equals a caller's id. Dict items were undeletable, with no error. Passing the dict itself instead raisedTypeError: unhashable type: 'dict', despite the parameter being nameditem_or_id. Dicts are the natural shape here (stream_insertaccepts anything), and nothing signalled the limitation. Fixed by extractingStream._identity(), which handles mappings and objects alike, and applying it to both sides of the comparison;delete()now treats aMappingas an ITEM rather than a bare id. Key presence decides, not truthiness, so{'id': 0}and{'id': None}resolve to their real ids instead of the address. An id-less dict resolves to its own address, so a look-alike matches nothing rather than deleting the wrong row, and the tombstone add is guarded for unhashable ids (removal still works, which is what callers observe;_deleted_idshas no readers anywhere in the codebase today, so skipping an entry cannot break a consumer — an earlier draft of this entry called it a client-diff optimisation, which was not true). Review caught that the cure reached only 2 of 3 sites (#1646):StreamsMixin.stream_deletestill emitted a dict repr as the dom_id while insert used the address, anddefault_dom_idusedor— truthiness — soid=0disagreed between insert and delete. Routing those through the shared helper then exposed a further distinction: the fallback for a known ITEM (its address) differs from the fallback for an ARGUMENT that may be a bare id (itself) —_identity(0)is the address of the int0. Split intoStream._identityandStream.resolve_idso the two cannot collapse again.Noneis treated as absent rather than as a value:{'id': 0}resolves to0(truthiness is not the test), but an id ofNonemeans no identity yet, so it falls through to the object address. Making it a value gave every unsaved row the SAME dom_id — three drafts all rendering asid="drafts-None"where the previous code gave unique addresses. That regression was introduced by the first version of this fix and caught in re-review; the original test used a single such item and so structurally could not see it (#1543). 18 cases intests/unit/test_stream_delete_dict_2116.py; gate-off verified (#1468): 8 of 18 fail without the mapping branch, reverting the public entry point fails the insert/delete agreement test, and restoring key-presence semantics fails the twoNone-identity tests. Scope note: this cures the DEFAULT dom-id factory. A stream created with a customdom_id=callable still disagrees between insert and delete — pre-existing, filed as #2121 rather than widened in. Found while testing #2112. -
Sync stream data never reached the template context, so the documented
self.stream(...)pattern rendered nothing (#2112).StreamsMixin.stream("messages", …)filled_streams["messages"].items, and_get_streams_context()exists to expose it — its docstring reads "Get streams data for template context" — but nothing ever called it, so the pattern indocs/website/guides/large-lists.mdstored the data and rendered nothing:{% for m in messages %}saw an undefined name. Why it survived:assert_stream_insertinspects the queued op list, which is populated whether or not the data is ever reachable, so a user's test went green while the page stayed empty — the feature had assertions, just not ones that could fail. Now wired inmixins/context.py:get_context_dataunder the documentedstreamsnamespace —{% for msg in streams.messages %}, as both call sites inlarge-lists.mdandStream's own docstring show. (A first pass splatted each stream as a bare top-level key; that is a second, undocumented spelling that also collides with ordinary view attributes, so a template written from the guide would still have rendered nothing while the fix looked correct.) Live stream data wins over an existingstreamskey. Deferring to an existing key looked like the safe, non-breaking choice and was the opposite:context["streams"]flowed into_cached_context→ the session snapshot → the restore paths'safe_setattr, makingstreamsa public attribute whose STALE value the attribute walk then fed back into the context — so the guard skipped the live data permanently and every insert after a restore was invisible. Root cure is thatstreamsis now excluded from the session snapshot entirely: it is derived from_streamson every call, not user state. That also removes ~45 KB of per-GET session bloat on the documented 500-item example (streams exist precisely to keep large collections out of state) and the un-serialized persistence of stream items. A view with NO streams is left completely alone, so an app usingself.streamsfor its own purposes and never callingstream()is unaffected. 14 cases intests/unit/test_streams_context_2112.py(context reachability, later inserts, deletes, multiple streams, name→key mapping, snapshot exclusion, a stale restored attribute not shadowing live data, an own-use view untouched, a pin that a bare top-level name is NOT exposed, and a pin on the tautology so the reason stays visible); gate-off verified (#1468). The full Python suite is the regression check here sinceget_context_datais on every view's path — 9918 passed, 0 failures. An adjacent pre-existing gap found while testing was filed rather than widened into the fix (#2116:Stream.deletecannot delete dict items, becausegetattr(item, 'id', …)reads an attribute a dict does not have). -
A patch whose
dj-idwas held DETACHED by a[dj-virtual]list silently landed on the WRONG node (#2113).getNodeByPathresolves bydj-idand falls back to the positional path when the id is absent — correct for an id-less patch or an id that changed while the path stayed valid. It is wrong when the id is absent because a virtual list holds the item off-window: the server addressed a known item, and the fallback silently retargets a different element whileapplyPatchesreturnstrueand nothing warns. Worse than the miss #2017-item-5 diagnoses — that one is loud and drops the update; this is silent and applies it somewhere else, and the reproducer shows it is not confined to a single row (aSetTextlanding on a container wiped its children: the snapshot of every[dj-id]'s text went from 4 rows to zero). Fixed by resolving the positional fallback FIRST and suppressing only when the resolved node lands inside a[dj-virtual]container — the dangerous case, since a windowed list keeps a contiguous block of positional ids (crates/djust_vdom/src/lib.rs) and a collision there means mutating the wrong row of the same list. Gating on the id alone (the first attempt) was wrong twice over: it dropped patches whose fallback legitimately landed on an unrelated live element elsewhere — trading a silent wrong-node bug for a silent dropped-patch bug — and it ran thestate.itemswalk on EVERY id-lookup failure, which is routine during rapid re-render, for a measured 21.7× regression (50 stale-id patches on a 10k-item list: 7 ms → 152 ms; it also never shared the #2017 diagnostic's throttle, since a successful fallback returns before reaching that call site). After the fix, the same A/B against the previous bundle is 11 ms → 9 ms, and the unrelated-sibling patch applies on both. The fallback is otherwise untouched, pinned by three tests: an id-less patch still resolves by path, a stale id with no virtual list still falls back, and a virtual list that is present but does NOT hold the id still falls back — without those this fix could have disabled path resilience on any page that merely contains a virtual list. 5 cases intests/js/dj-virtual-path-fallback-2113.test.js; gate-off verified (#1468): gating the guard fails exactly the 2 bug cases and leaves the 3 fallback-preservation cases green. -
Only the FIRST LiveView root was scanned for
dj-window-*/dj-document-*, and the eviction sweep disagreed with it on scope (#2110)._scanScopedElements()resolved one root viaquerySelector('[dj-view]') || '[dj-root]'while the eviction sweep walked the whole registry document-wide. Because the two scopes disagreed, an entry outside the selected root was neither refreshed nor evicted and kept dispatching its ORIGINAL handler forever. The reproducer showed the gap is wider than the issue described: a second root's scoped attrs never registered at all — not merely went stale — sincequerySelectorreturns only the first match, so<div dj-view="app.A"></div><div dj-view="app.B" dj-window-keydown="fromB">left B's directive completely dead. Two independently load-bearing halves: (1) scan every[dj-view]/[dj-root], each plus its descendants — a nested root is visited twice (as a descendant, then as a root), which is harmless becausescanElementREFRESHES an existing entry rather than adding a second one (#2108), pinned by a no-double-fire test; (2) evict entries whose element is no longer governed by ANY root, since an element that leaves the root but stays in the document can never be refreshed and so must not keep firing. Gate-off verified (#1468) per half: scan-all-roots off fails the two multi-root tests,isGoverned-eviction off fails the moved-out test. Only the outermost roots are scanned: containment is transitive, so a nested root's subtree is already covered by its ancestor and the governed set is exactly equivalent, but walking every root re-walks each nested subtree once per level. The first implementation did exactly that, and the cost proved quadratic in nesting depth — measured +7.1% at depth 2, +19.5% at depth 4, +49.8% at depth 10 and +308% at depth 50, with the realistic{% live_render %}shape at +10.1% (an earlier claim of a flat +5% was wrong: it generalized from a depth-2 measurement). After filtering to outermost roots the same A/B is flat in depth — depth 50 drops from +308% to +8.7%, and depths 1–10 sit within measurement noise. Filtering is a single document-order pass (an ancestor always precedes its descendants), so it costs O(n) containment checks rather than O(n²). Residual known characteristic, measured and accepted:isGovernedis O(entries × roots), so a page with many genuinely SIBLING roots degrades (~+14% at 10 roots, ~+42% at 100); nested roots collapse to one entry so they do not contribute. Behavior change worth noting on upgrade: the oldquerySelector('[dj-view]') || '[dj-root]'ignored[dj-root]entirely whenever any[dj-view]existed, so a scoped attr inside a[dj-root]but outside the[dj-view](e.g. an<aside>sibling) was inert; it now fires. That is the intended scope — the element is inside the patched region — but it means previously-dead directives activate. 5 cases intests/js/dj-window-scope-symmetry-2110.test.js(moved-out element, multi-root orphan, second root scanned at all, nested child root still working, no double-fire when reachable from two roots); sibling suites (#2097, #2108, #1996,dj-window-events) all still green at 26 passing. Bundle: +101 bytes gz. -
Scoped-attr registry kept a STALE handler when the attribute value changed in place (#2108).
_scanScopedElements()(static/djust/src/09-event-binding.js) deduped registry entries on(element identity, attrName)and skipped on a match — the attribute VALUE was never compared andentry.parsedwas never refreshed. So when a server re-render changed a handler on a surviving element (dj-window-keydown="old"→"renamed"), the new value was dropped and the old handler kept firing indefinitely. In-place mutation is the realistic shape: morphdom patches a surviving element's attributes rather than replacing the node, so an element that is replaced dodges the bug (thedocument.contains()sweep evicts its entry) while one that merely survives does not — including thedj-root/dj-viewelement, which is the morph anchor and never replaced. Surfaced by the Stage 11 review of PR #2107 (which, by adding the root to the scan, made this path more reachable) and confirmed pre-existing on the descendant path too. Two independently load-bearing halves: (1) REFRESHentry.parsed+entry.requiredKeyon match instead of skipping — covers a renamed handler, changed inline args, and a changed key filter; (2) EVICT entries whose attribute is gone — thecontains()sweep only catches removed ELEMENTS, so an element that survived while the server dropped the attribute kept dispatching a directive the template no longer declares. Half 2 was found by the reproducer; the issue only described half 1. Gate-off verified (#1468) per half, so neither is dead code: eviction-off fails exactly the key-filter test, refresh-off fails the other three, and reverting both fails 4 of 5. 5 cases intests/js/dj-window-stale-handler-2108.test.js(descendant rename, root rename, key-filter swap escape→enter, unchanged-value no-double-register across 5 binds, and inline-arg refresh with an UNCHANGED handler name — the nastiest shape, since the event still looks correct on the wire). All drive realwindowKeyboardEvents through the realbindLiveViewEvents()path (#1196). Bundle: +34 bytes gz. -
dj-window-*/dj-document-*written ON thedj-view/dj-rootelement itself were silently dead (#2097)._scanScopedElements()(static/djust/src/09-event-binding.js) resolved the LiveView root and then scannedroot.querySelectorAll('*')— which matches descendants only, so the root element's own attributes were never examined and no registry entry was ever created. The failure is entirely silent: no client error, no WS frame, andwindow.djust.handleEvent('key', {...})called by hand works perfectly, which sends you hunting in the event path rather than the delegation registry. Moving the same attribute one level deeper fixes it, which makes the bug especially confusing to diagnose. Reported from the snake-arena downstream app, whose game board IS thedj-viewroot and carriesdj-window-keydown="key". Fixed by including the root in the scan list; whenrootfalls back todocumentnothing is prepended (document has no attributes, anddocument.querySelectorAll('*')already covers<html>/<body>). Scope note: #2097 also reported a post-morph stale-registry cause; that one was verified empirically to b