Skip to content

v1.1.0rc9

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 27 Jul 19:14
· 283 commits to main since this release

Changed

  • The benchmarks job now fails the aggregate test-summary check (#2160). It shipped continue-on-error: true under 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 them main pushes, at ~13% of budget (vdom_diff_list_reorder median 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-level continue-on-error makes needs.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 to test-summary's needs and to its AND-chain (the load-bearing step — in needs alone 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 that main has no required status checks at all, so a red test-summary does 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-level continue-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.py wraps the event-path state save in asyncio.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 the TimeoutError is swallowed, a warning is logged, and liveview_<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 2 to -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 now EVENT_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; a generous_save_timeout fixture in python/djust/tests/conftest.py does that for all 13 affected tests across four files — including test_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 * 200 is 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 and logger.warning(...); raise at 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.py told readers that "the benchmark-gated CI job (--benchmark-only serial) enforces it". It did not: a grep for --benchmark-only across all twelve workflows returned nothing, and that job had never existed. Because _assert_benchmark_under skips its threshold whenever benchmark.stats is unavailable — which is what pytest-benchmark does under xdist, and every CI job plus make test run -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 on main while measuring the environment rather than the code (found by scripts/pre-push-pytest.sh on its first real use). The targets were measured, not adjusted: serially on a quiet machine all 58 benchmarks pass, with vdom_diff_list_reorder at 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 in test_redis_serialization_performance and flaked again. A benchmarks job now runs them serially with thresholds enforced, explicitly not under -n auto (which would silently disable the stats it exists to assert on). It ships continue-on-error: true and outside test-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 in needs and looks enforced without gating anything (#1713), cannot ship. 7 cases in test_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_nothing scanned the whole workflow file, and test-summary's echo block mentions every job — so a reviewer shipped the complete half-promoted state (job de-flagged, added to needs, 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 used importorskip("yaml"), which made all seven checks evaporate as 1 skipped with the suite green (PyYAML is undeclared, arriving via uvicorn[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-rolled pip install uv while six sibling jobs in the same file use astral-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_performance asserted set_time < 0.1 and get_time < 0.1 on 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 asserts serialize_msgpack/deserialize_msgpack are each invoked exactly once per set/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 second serialize_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_current could 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 venv creates no pip, so .venv/bin/pip install maturin had no interpreter, and the line was redundant anyway because maturin is already a dev dependency the preceding uv install had placed. Replaced with the uv sync --extra dev / uv run maturin develop --release pair that test.yml proves 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 if test.yml moves off it.

  • The Action Tracker's open count now means something, and chain-shaped issues have a convention (#2143, #2142). Nothing closed a RETRO.md Action 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 at Open against an issue closed a full milestone earlier and was found by chance. The count is quoted in retros as a health signal; running the new scripts/check-action-tracker.py against the real file found 65 of 70 Open rows 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 one gh call, and reports disagreement in both directions — the reverse case (a Closed row 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; --fix therefore 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.md gains 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_name attribute from every rendered form field (#2145). python/djust/frameworks.py put data-field_name="<name>" on every widget the CSS-framework adapters render, next to dj-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 the dj-auto-recover container's own attributes); every element.attributes iterator and every .dataset read in static/djust/src/ was checked and there is no third. extractTypedParams runs on six call sites — dj-click (:601), dj-poll (:1206), dj-click-away (:1264), dj-shortcut (:1336), dj-mounted (:1365) and dj-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 through buildFormEventParams (:525), which merges only dj-value-*. So the one collector that would have read it never ran on the element carrying it. What carries the name instead is name="<field_name>", which all three renderers set unconditionally and which getFieldName (:504) reads as its second fallback after data-field; the reconnect path agrees, _processFormRecovery (:1773) reading field.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 the if auto_validate: guard the other two kept it inside, so auto_validate=False rendered data-field_name on 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 because FormMixin.validate_field took field_name while the client sends field, and PR #2141 corrected the two docstrings asserting "djust maps data-* attributes to handler parameters" — true of the click/poll/mount paths, false of the form-event path. Anyone opening frameworks.py saw an attribute apparently doing exactly that and re-derived the belief. Also corrects three wizard.py docstrings claiming as_live_field() emits data-field="<name>"; it does not and never did, and that claim is load-bearing here, because the name fallback is the whole reason the deletion is safe. One boundary this does not claim: dom_event= and widget.attrs are caller-controlled, so a caller who attaches a click-family directive to a form field does get that element's data-* 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 in TestNoDataFieldNameAttribute (all three renderer sites individually per #1104, across all three adapters, plus two structural pins) and 3 in tests/js/no_data_field_name_2145.test.js, which makes the reachability chain executable: dj-change yields no field_name even with the attribute re-added, while dj-click on 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 (dropping name 12, emitting dj-click 3), as are the JS cases (merging data-* into buildFormEventParams 1, dropping extractTypedParams from the click path 1).

  • A **kwargs handler no longer swallows a near-miss parameter silently (#2144). #2137 was one instance of a class: the client sent field, FormMixin.validate_field took field_name, and because the signature also had **kwargs the mismatch was absorbed instead of raising TypeError — 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_params already computed the unexpected-key set and then discarded it whenever **kwargs was 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) reading kwargs directly), 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 **kwargs handler 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 because runtime.py:2734 reads it with .get where :3457 pops it, and component_id); underscore-prefixed keys are excluded wholesale so a future _foo cannot 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 in test_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 **kwargs handler no longer swallows a near-miss parameter silently (#2144). #2137 was one instance of a class: the client sent field, FormMixin.validate_field took field_name, and because the signature also had **kwargs the mismatch was absorbed instead of raising TypeError — 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_params already computed the unexpected-key set and discarded it whenever **kwargs was 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) reading kwargs directly), 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 used difflib at a 0.6 cutoff and was wrong in both directions — it claimed data was a typo of date, from of form and id of uid (confidently wrong advice about djust's own parameter names, worse than the silence it replaced), while staying silent on name against field_name (0.571) and page against page_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 in test_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 configured sensitive_field_types, so it is now memoized per (field class, configured frozenset) key. A LIVEVIEW_CONFIG mutation 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 by TestTypeFloorMemo::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 in test_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 emit VirtualInsert/VirtualMove/VirtualRemove for a [dj-virtual] parent; nothing could apply them. Now 12-vdom-patch.js routes all three through 29-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 enters state.items: not counted for the spacer height, not reachable by scrolling, dropped on the next render. before_key is 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_key names 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 spelled dj-key or data-key (the Rust parser reads both into VNode.key while a list's own keyAttr defaults to data-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 single finally wrapping 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 a createElementFromVNode that does not exist (the builder is createNodeFromVNode), so every insert silently failed; and the new itemKey() redefined the variable-height cache's itemKey() — 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 existing virtual_list tests caught the second; running only the new file would not have. 18 cases in tests/js/virtual-keyed-ops-2017.test.js; gate-off verified per guard, every mutation asserting it applied before the run (patcher arms 12, before_key ignored 4, flush 1, natural phases 1, dj-key fallback 1, duplicate-key replace 1, nodeType guard 1, DIRTY.clear() outside its finally 1, 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 the dj-key case configured dj-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_keyed recurses 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 emitted SetText carries 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 row k0 after a scroll silently rewrites k7, with applyPatches returning true and no warning. Not introduced here (iteration 1 emits them, dark) and the same shape exists on the ordinary reconcile_keyed path, but the earlier record of this gap named only InsertSubtree/MoveSubtree and 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() defaults false, and a test pins that a [dj-virtual] parent with the flag OFF emits byte-identical patch kinds to an ordinary parent, so nothing on main changes 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_if is 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 as 0x93 (a positional FIXARRAY) and fails its own round-trip with "invalid length 2, expected 3 elements" because skip_serializing_if dropped the interior d. That is the #1541 shape, it is pre-existing across every Patch variant, and it is latent only because render_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 in wire_protocol_snapshot.rs rather than asserted away; the two comments in lib.rs that stated the opposite are corrected. The new variants keep skip_serializing_if to match every sibling variant, because the LIVE path is serde_json, where it is genuinely safe; and the flag is a process-global atomic rather than threaded through diff_nodes, which is a pure free function reached from several entry points. apply_patch models 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_keyed reuses longest_increasing_subsequence — the same minimisation reconcile_keyed already 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 a VirtualMove, so appending one item to a 50-item list emitted 50 moves, and on the 10k-row feeds dj-virtual exists 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-addressed RemoveChild (the routing gate was any_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_key addresses 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 (SetText at 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 a dj-if-boundary case where the two indices differ. Stated precisely because a first draft of this entry claimed the dj-if case 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 indexed new_nb and is correct whenever new_nb is 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_key names an anchor an earlier op placed) and was undocumented; a test now phase-sorts the ops the way the client's _sortPatches sorts 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 in wire_protocol_snapshot.rs — that suite exists for exactly this contract and had zero Virtual coverage, the v1.0.0rc4 retro finding #1 shape verbatim. 20 cases in crates/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 in wire_protocol_snapshot.rs; gate-off verified per fix; full djust_vdom suite 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-virtual differ 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 in 12-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/_virtualPrune rather 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 in state.items with off-window rows detached. 17-streaming.js had zero dj-virtual awareness and mutated the element directly, so the pairing documented in large-lists.md — stream the data, virtualize the rendering — did not work without bespoke app JS. append/prepend now splice into the pool and prune trims 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 .children prunes 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_opshandleStreamMessage) is the one that matters and was never blocked. 5 cases in tests/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 in state.items, so a server patch aimed at one legitimately resolves to null. 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. New window.djust._findVirtualListHolding(djId) re-discovers [dj-virtual] containers from the DOM (STATE is 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 than djustDebug-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 differ dj-virtual awareness, 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 of state.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 in tests/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 the document.contains guard fails 1, dropping root-scoping fails 1. Bundle: +373 bytes gz.

  • Official adapters — dj-chart pilot, "user brings the library" (#2063, ADR-025 milestone C). ADR-025 shipped the two extension sockets (JS.ext.* custom commands #2051, dj-hook typed 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 at python/djust/static/djust/ext/dj-chart.js and is enabled with DJUST_CONFIG = {"extensions": ["chart"]} — djust ships only the morph-safe glue (a pre-written dj-hook plus pre-registered JS.ext commands chart_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 calls update() 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 clear console.error per element — not per re-render, since updated() retries the mount and would otherwise flood the console — and never throws, so other hooks on the page still mount. Not bundled into client.js: the shared ~87 KB gz budget is unchanged for anyone who doesn't opt in (pinned by a test asserting client.js contains no adapter code); the adapter is 2.3 KB gz, fetched only when enabled. python/djust/extensions.py is 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_scriptdefer preserves document order, which is the whole reason window.djust.commands.register is 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 check djust.C015 reports an unknown adapter name (and a non-list extensions value) 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 by test_chart_is_the_only_shipped_adapter) — each is an ongoing maintenance commitment, so dj-sortable/dj-editor wait for demand. Self-review caught and corrected a wrong recipe before merge: the initial header told users to add dj-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 update dj-hook-value-data and 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's width/height. Tests: 17 cases in tests/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 REAL check_configuration() aggregate — without them the production call site could be deleted with the whole suite still green, the decorative-pin class #1859) + 10 cases in tests/js/dj_chart_adapter.test.js, driven through the REAL hook dispatcher (djust.mountHooks/updateHooks/destroyAllHooks) and the real js._executeOps dispatch 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: new docs/website/guides/official-adapters.md, linked from both _config.yaml and index.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 every dj-* template directive, every djust.js.JS chain command (DERIVED via dir(JS) introspection, not hand-listed), and the public LiveView API (reused from get_framework_schema()'s sections, not reinvented). Works without Django setup, same framework-only contract as get_framework_schema(). New management command djust_surface_manifest (mirrors djust_schema's shape) prints it as JSON, --indent optional. 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 in test_surface_manifest_2064.py (TestJSCommandTriParity, TestDirectiveBindingParity, TestEqualityShapeCoverage, TestManifestShape, TestGateOffNonVacuous) — a manifest that can silently drift is decorative (#1859). TestJSCommandTriParity structurally extracts the client chain-factory method names from static/djust/src/26-js-commands.js and asserts client == Python djust.js.JS == manifest; TestDirectiveBindingParity extracts every dj-* attribute the client binds via one of the covered call shapes across all 55 static/djust/src/*.js modules (regexes anchored to real DOM-binding call shapes: selector arguments, get/has/set/removeAttribute, startsWith prefix checks, attribute-name constants, and strict-equality name === 'dj-X' / m.attributeName === 'dj-X' reads — the last being the sole observation trigger for the MutationObserver modules, pinned by TestEqualityShapeCoverage) and asserts each is documented in DIRECTIVES or in the explicit, justified _STRUCTURAL_EXCLUSIONS/_PREFIX_FAMILY_EXCLUSIONS dicts (dj-id, dj-root/dj-view, the sticky-child auto-emitted markers — never user-authored). Dogfooding (#1459) ran both canaries against the pre-existing DIRECTIVES (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 new DIRECTIVES entries (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) plus related_attributes on dj-copy/dj-hook/dj-input for their sibling modifier attributes (dj-hook-value-*/dj-hook-target on dj-hook closes the exact #2064-cited gap). TestGateOffNonVacuous proves both canaries are load-bearing: removing a real DIRECTIVES entry, prefix family, or Python JS method from the in-memory comparison makes the coverage check correctly report the gap (also verified on-disk: temporarily removing dj-click-away from schema.py and push from js.py and re-running the suite both produced real RED, restored after); TestEqualityShapeCoverage adds an empirical canary (#1459) that injects an equality-only name === 'dj-probeonly' binding and proves the extraction catches it only while the name === 'dj-X' regex is wired in.

  • Stop committing .min.js.gz / .min.js.br / .min.js.map build siblings — kill cross-toolchain churn (#2054). client.min.js.gz/.br/.map and debug-panel.min.js.gz/.br/.map were tracked in git; every local scripts/build-client.sh run (via the build-js pre-commit hook or make build-js) touched them on disk, and a broad git add/git commit -a swept the diff into unrelated PRs even when the source and .min.js itself were byte-identical (PR #2051's commit 31354b91 carried +998 B/-30 B of debug-panel.min.js.br/.gz diff with zero debug-panel source changes). Root-caused two distinct drift sources: (1) gzip/brotli are unpinned system CLI tools (unlike terser, which is npm-pinned via package-lock.json), so their compressed output isn't guaranteed byte-identical across contributor machines even for identical input; (2) .map files are always non-reproducible — terser's sources field 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 .map diffs 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 references client.js/client.min.js (never the compressed siblings); C013's stale-bundle check hashes only client.min.js; the documented WhiteNoise deployment pattern (whitenoise.storage.CompressedManifestStaticFilesStorage, as configured in djust.org's own settings.py) regenerates its own .gz/.br at collectstatic time 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/.map files are now gitignored, build-time-only artifacts (scripts/build-client.sh still generates them locally for make start/local WhiteNoise testing) — client.min.js/debug-panel.min.js themselves stay committed unchanged, since terser's pinned-toolchain output is genuinely deterministic. tests/unit/test_client_minified.py::test_gzip_sibling_exists_after_build now skips gracefully (instead of hard-failing) when the artifact is absent, matching the existing client.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/.map and 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_diff wins on reorder benchmarks). Opt out with LIVEVIEW_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 — including test_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 every RustLiveView; 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 in test_loop_render_cache_1967.py flipped to test_config_default_is_true with 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 found djust_demos/ × 3 and the second copy of status_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), and docs/state-management/IMPLEMENTATION_PHASE2.md. Every figure said ~5KB for a client that ships at 58.7 KB gz — wrong by ~11×, and stale since PR #796/#800 per RETRO.md:50, i.e. copied forward across four releases. The corrections are written as ~58 KB gz deliberately, not as a house-style choice: gz is 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.md is deliberately NOT renumbered. It is a dated plan ("Status: In Progress, Started: 2025-01-12") whose success criterion reads Client 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 at make sizes. One marker rule. _SIZE_ARTIFACT_MARKERS was 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_MARKER was 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 to shipped and 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 in CLAUDE.md. The two build guards no longer mask each other. write_size_manifest's missing-.gz skip 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-.gz case still ends at rc 1 via the refusal; with the refusal removed, a present .gz plus an empty src/ 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_listed scans tracked .md/.txt/.html/.py files and requires every checker-visible client-size figure to be either checked against the measured manifest or listed in _KNOWN_UNGUARDED_SIZE_CLAIMS with 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 further docs/state-management/ design docs carry the same 2025-01 figures, including STATE_MANAGEMENT_API.md:1869's client.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.yaml links 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 runs check-doc-snippets.py unconditionally inside the test-summary merge gate, so every newly guarded file is enforced there. The pre-commit hook's files: trigger is not widened to cover them — it would run a django.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.json change. That gap pre-dates this PR (docs/llms.txt and four others have had it since #2147); this widens the exposure without changing the enforcement point. 24 cases in tests/test_client_size_manifest_2138.py (was 15). New cases in TestEachBuildGuardIsIndependentlyLoadBearing and TestOneMarkerSpellingRule, 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 main is found before someone pushes into it (#2139). main carried 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.sh now 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.yml runs the suite on main daily 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 .so via head -1 where 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 before no 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, since count(...) >= 3 tolerates losing exactly the path that would then guess silently.

  • A blocked push now says whose failures blocked it, and a red main is found before someone pushes into it (#2139). main carried 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.sh now 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.yml runs the suite on main daily 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 .so via head -1 where 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 with no tests ran and 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 $FAILED unquoted split a parametrized test_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.py now 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 main is found before someone pushes into it (#2139). main carried 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.sh now 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.yml runs the suite on main daily 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 .so via head -1 where 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 with no tests ran and 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 $FAILED unquoted split a parametrized test_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.py now 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. The sed '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/python while the main run went through run-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 main is found before someone pushes into it (#2139). main carried 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.sh now 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.yml runs the suite on main daily 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 .so via head -1 where 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 with no tests ran and 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 $FAILED unquoted split a parametrized test_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.py now 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. The sed '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/python while the main run went through run-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 hardcoded ROOT/.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, where not found is 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 in TestExitCodeClassification and 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 (~87 KB against 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 on main that 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.sh now writes client-sizes.json, the single measured source naming both artifacts, and it is committed — the .gz files 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.py resolves the artifact per line rather than per file (a doc legitimately cites both, so a per-file mapping cannot work) and now reads CLAUDE.md too, 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 said client.js (~58 KB gz) when the shipped artifact is client.min.js.gz and client.js is 188 KB — mislabelled, not merely stale. make sizes prints the current figures so updating prose is mechanical. 15 cases in tests/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 — because min_gz defaulted to 0 and the gzip call was unguarded while the identical call 80 lines above guards on command -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 command make sizes tells you to run, with emitted advice that would have had a contributor silence four correct figures. It now skips rather than zeroes, guards gzip, and hard-fails on any zero measurement. And the per-line resolver could never resolve unminified: "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 in CLAUDE.md had 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 runtime in README.md:91wrong by ~11× against the 58.7 KB it actually injects — nor eight more across docs/. 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 × ~5KB plus a stale ~63KB in TEMPLATE_BACKEND.md), and it is pinned in both directions — the ~5KB shape 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 ~5KB copies remain, almost all in examples/demo_project/ rendered demo copy, plus docs/state-management/IMPLEMENTATION_PHASE2.md and a stale ~87 KB in ROADMAP.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-commit build-js hook never staged the manifest, so every JS-touching commit left it drifting; check-doc-snippets did not fire on CLAUDE.md; and the on-disk assertion compares KB within tolerance rather than exact gz bytes, since build-client.sh gitignores .gz precisely 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_keyed recursed 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 row k0 after a scroll silently rewrote k7, with applyPatches returning true and no warning; an off-window row's update could not land at all. New VirtualUpdate { 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 in state.items, unreachable by path and findable by key. ADR-026 Option A sketched an Update(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 the dj-if boundary 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 emits Replace { 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 for node.parentNode — off-window (detached) that threw; in-window it succeeded against the shell while the pool kept the old node, so applyPatches returned true with no warning and the change reverted the moment the row scrolled out and back, since render() 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 _virtualReplaceByKey seam, 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 covers SetAttr on the row itself, RemoveAttr, InsertChild, Replace in-window and off-window, Replace-then-patch, and survival across a scroll. The querySelector trap is real but dodged — scope.querySelector never matches the scope element, so a row targeting itself by dj-id would miss, and it works only because of the path.length === 0 early return, which nothing pinned until now. VirtualUpdate is also added to the wire-protocol snapshot suite: it is the first recursive Patch payload, 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-Replace not special-cased 4, pool swap writing the wrong slot 4, differ back to path-addressed 2). Three test-design corrections: the scroll test targeted k0, 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's gzip.compress, which embeds an mtime).

  • FormMixin.validate_field accepts the key the client actually sends (#2137). The client sends the field name under field09-event-binding.js at all three of its send sites and 20-model-binding.js for dj-model, because buildFormEventParams (09-event-binding.js:525) hardcodes that key, sourcing it from getFieldNamedata-field, then the element's name, then its id — while the Python signature took field_name exclusively. (An earlier draft of this entry said "djust maps data-* attributes to handler parameters", which is true of extractTypedParams on the click/poll/mount paths and false of the form-event path; the claim was inherited verbatim from wizard.py, where it is equally wrong, and both are corrected.) The **kwargs in that signature is what made it silent: the payload matched no named parameter, **kwargs absorbed it instead of raising TypeError, 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_field carries the same field or field_name coalesce 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. field wins when both are supplied (it is the contract; field_name is the compatibility alias), matching WizardMixin's resolution order. Also renamed a local the change itself made shadow a parameter — field = form.fields.get(field_name) rebound the new field parameter, 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 in tests/unit/test_validate_field_wire_contract_2137.py asserting what the caller observes rather than which parameter carried the name, including two structural pins: every validate_field in the tree accepts field, and each one actually coalesces rather than accepting the parameter and ignoring it. field is the first positional parameter deliberately, and this also fixes djust admin: admin_ext/adapters.py emits validate_field('<name>', value) at 8 sites, where the bare value token arrives as the literal string "value" in positional slot 1 — with field first that junk lands in field_name and 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 that validate_field("email", "text") binds field_name="text" and leaves value=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 — documented field_name as 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: **kwargs converts a param-name mismatch into silence, and every handler with **kwargs (the documented, recommended shape) has the same exposure — validate_handler_params already computes the unexpected-key set and discards it when has_var_keyword. 11 cases. Gate-off verified per guard, every mutation asserting it applied: field param dropped 9, FormMixin coalesce removed 6, resolution order flipped 3, signature reordered 2, admin_ext coalesce 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 filtered items via resolve_id/_identity and 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_id returned .id whenever the attribute existed, without _identity's is not None discipline, so a row with id=None, pk=5 resolved to None on one side and 5 on the other, and an unsaved row (both None) to None versus its object address — delete() compares one against the other, so neither was ever removable, with the default factory and no custom factory involved. resolve_id now routes items through _identity via the _looks_like_item predicate, 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 reused dom_id_for — a string formatter — as a per-item comparator, so any two values whose str() matched collapsed into one row and deleting one destroyed the other. Rows keyed 5 and "5", or a UUID and its own string form (ordinary Django, a UUIDField pk beside the string an event-handler param arrives as), were both deleted where main correctly 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. A getattr(m, "code", DEFAULT)-style factory gives every keyless row the SAME key, and the fix for DEFAULT=None covered 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, where main correctly 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 the None special-case and is strictly better than it: when exactly one row carries a None key, 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 the try — a value whose __eq__ or __str__ raises was propagating out of stream_delete and aborting the handler. The predicate is key == target_key and f"{key}" == f"{target_key}" — same value and same rendered dom_id, both halves independently pinned. An interim type(a) is type(b) guard was wrong in the other direction: it rejected an IntEnum row against a plain-int argument and a SafeString against a str, 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 is identity ∪ 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, where main correctly 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_id reverted 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 the try 4, 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 iterated self.items live 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 with list(...) 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 a SyntaxError, 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_if from every Patch variant (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_named encodes 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; and skip_serializing_if on the interior d drops its slot when None, 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 read index where it expected d. 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 is VNode.djust_id, whose #1538 fix is sound only while that field is trailing; cached_html is 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_msgpack takes djust_core::Value — djust's own #[serde(untagged)] enum, not serde_json::Value as 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-written Deserialize added 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 in python/djust/tests/test_binary_diff_msgpack_2130.py drive the real PyO3 producer and decode with the msgpack library a consumer would use, plus 2 in wire_protocol_snapshot.rs (30 total) — one of which keeps the positional encoding's brokenness as the recorded reason the producer uses to_vec_named, so nobody simplifies it back. The first draft of the headline test asserted only that unpackb() 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 after djust_id but not one declared the way djust_id itself is (#[serde(default, skip_serializing_if = ...)] — the line directly above, and so what a future author would copy), where djust_id silently absorbs the new field's value on the live view-state restore path. Both canaries verified red against real VNode. One caveat the size argument needs: named msgpack is ~30% smaller than JSON raw, but under permessage-deflate the 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-Vec branch 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_event failed 2 of 5 runs on an unmodified main and 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_tick is 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_once is 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_interval set AND handle_tick overridden) 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 new StaticTickView whose handle_tick is 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 the TestTickAtMount class: 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 called maybe_start_tick_task directly, so deleting its call site in runtime.py left all 9948 tests green while every tick_interval view 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 driving WSConsumerTransport.on_view_mounted for 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 reaches stream_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_delete did not — it resolved independently through Stream.resolve_id, so a stream created with dom_id=lambda m: m["slug"] inserted rows as rows-hello-world and deleted rows-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's 17-streaming.js speaks StreamingMixin's separate {op, target, html} protocol, exactly as the #2017 item 1 entry above says. So this fixes an internal contract — the op-dict shape LiveViewTestClient reads, 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_id replaces the closure that lived inside stream() (a closure is a fresh object every call, so nothing could distinguish a default factory from a custom one by identity), and re-calling stream() with a new dom_id= on an existing stream emitted ids from the new callable while stream_insert/stream_delete kept 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_delete additionally tolerates an item the factory rejects ({"id": 1} under a m["slug"] factory — a natural shape right after a DB delete, and its parameter is literally named item_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 made dom_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 in tests/unit/test_stream_custom_dom_id_2121.py asserting agreement between the emitted ops rather than particular strings, plus a structural pin that streams.py never formats the "{name}-" prefix itself and has exactly 3 dom_id_for call sites. Gate-off verified (#1468) per behaviour, counts measured after the final test set rather than carried over from an earlier one (#1049): reverting stream_delete to 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 through stream_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() returned stream_obj.items — the same list object the stream keeps mutating — and _reset_streams() calls Stream.clear(), an in-place items.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 in tests/unit/test_streams_capture_aliasing_2119.py following 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.delete silently could not delete dict items (#2116). Identity was resolved with getattr(item, "id", getattr(item, "pk", id(item))). getattr reads an attribute, and a dict has none — so a dict item fell through to id(item), the CPython object address, which never equals a caller's id. Dict items were undeletable, with no error. Passing the dict itself instead raised TypeError: unhashable type: 'dict', despite the parameter being named item_or_id. Dicts are the natural shape here (stream_insert accepts anything), and nothing signalled the limitation. Fixed by extracting Stream._identity(), which handles mappings and objects alike, and applying it to both sides of the comparison; delete() now treats a Mapping as 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_ids has 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_delete still emitted a dict repr as the dom_id while insert used the address, and default_dom_id used or — truthiness — so id=0 disagreed 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 int 0. Split into Stream._identity and Stream.resolve_id so the two cannot collapse again. None is treated as absent rather than as a value: {'id': 0} resolves to 0 (truthiness is not the test), but an id of None means 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 as id="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 in tests/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 two None-identity tests. Scope note: this cures the DEFAULT dom-id factory. A stream created with a custom dom_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 in docs/website/guides/large-lists.md stored the data and rendered nothing: {% for m in messages %} saw an undefined name. Why it survived: assert_stream_insert inspects 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 in mixins/context.py:get_context_data under the documented streams namespace — {% for msg in streams.messages %}, as both call sites in large-lists.md and Stream'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 existing streams key. 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, making streams a 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 that streams is now excluded from the session snapshot entirely: it is derived from _streams on 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 using self.streams for its own purposes and never calling stream() is unaffected. 14 cases in tests/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 since get_context_data is 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.delete cannot delete dict items, because getattr(item, 'id', …) reads an attribute a dict does not have).

  • A patch whose dj-id was held DETACHED by a [dj-virtual] list silently landed on the WRONG node (#2113). getNodeByPath resolves by dj-id and 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 while applyPatches returns true and 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 (a SetText landing 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 the state.items walk 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 in tests/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 via querySelector('[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 — since querySelector returns 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 because scanElement REFRESHES 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: isGoverned is 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 old querySelector('[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 in tests/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 and entry.parsed was 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 (the document.contains() sweep evicts its entry) while one that merely survives does not — including the dj-root/dj-view element, 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) REFRESH entry.parsed + entry.requiredKey on match instead of skipping — covers a renamed handler, changed inline args, and a changed key filter; (2) EVICT entries whose attribute is gone — the contains() 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 in tests/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 real window KeyboardEvents through the real bindLiveViewEvents() path (#1196). Bundle: +34 bytes gz.

  • dj-window-* / dj-document-* written ON the dj-view/dj-root element itself were silently dead (#2097). _scanScopedElements() (static/djust/src/09-event-binding.js) resolved the LiveView root and then scanned root.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, and window.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 the dj-view root and carries dj-window-keydown="key". Fixed by including the root in the scan list; when root falls back to document nothing is prepended (document has no attributes, and document.querySelectorAll('*') already covers <html>/<body>). Scope note: #2097 also reported a post-morph stale-registry cause; that one was verified empirically to b