This is a pre-release. djust 1.2.0 has shipped since.
Part of djust 1.2 — read the 1.2 release notes.
Added
@debounceand@throttlenow work — the client half is implemented (#2656). Both decorators previously stamped handler metadata that nothing in the shipped client read:debounceTimers/throttleStatewere declared instatic/djust/src/04-cache.jsand only ever cleared on disconnect, and thewindow.handlerMetadatascript written bymixins/template.pyhad no readers at all — so a decorated handler fired on every event exactly as an undecorated one did.The consumer is a new client module,
static/djust/src/05-handler-rate-limit.js, gating dispatch at the top ofhandleEvent.@debounce(wait, max_wait)collapses a burst of events into one send carrying the LAST payload, withmax_waitbounding the total delay from the first event of the burst.@throttle(interval, leading, trailing)caps sends at one per interval, honouring both edges as its docstring already described. A pending send is also flushed ondj-submit, so a debounced field edited immediately before submit is not lost (#1278).The configuration reaches the browser on the mount frame as
handler_config(ViewRuntime._extract_handler_config), the same route@cacheuses forcache_config— CSP-strict, and unaffected by the #1610 mount morph that does not re-execute the inlinehandlerMetadatascript.window.handlerMetadatais retained as the HTTP-path fallback, where no mount frame is ever delivered.Costs 603 bytes gzipped (
client.min.js.gz59,945 → 60,548 B; 58.5 → 59.1 KB), measured like-for-like from the committedclient-sizes.jsonmanifest on both sides. An earlier draft said 584 B, which mixed toolchains —mainmeasured by a localgzip, the branch read off its manifest.A pending debounced/throttled send is flushed on
dj-submitbut NOT on socket close or page unload, where it is dropped; handler config also accumulates acrosslive_redirectmounts. Both mirrorsetCacheConfig's existing behaviour and the element-leveldj-debounce's existing unload gap, so they are pre-existing shapes rather than new ones — but@debouncebeing inert before meant nothing could be lost, so the reach is new. The trade is stated in the@debouncedocstring and tracked in #2705.@optimisticis not included: it takes no arguments and so declares no DOM change for a client to apply. It stays marked INERT, now tracked in #2699 — which also records that the already-wired DEP-002optimistic_rulespath covers the same ground. The INERT caveats added in #2655/#2690/#2694 are removed for@debounce/@throttleacross the docs,schema.py(descriptions and theusagesnippets an agent pastes, closing #2696),mcp/server.py,BEST_PRACTICES_AI.mdand the decorator docstrings, and kept — re-pointed at #2699 / #2680 — for@optimisticand@client_state.That sweep missed three lines of
docs/state-management/STATE_MANAGEMENT_API.md— the filedocs/README.mdcalls the complete decorator reference — which still said@debouncewas "a marker with no client implementation", five lines under a banner the same commit had corrected to say the opposite. Neither guard could see them: the schema pin reads onlyschema.py, and the checker'sINERT_DECORATOR_USEmatches@client_state.scripts/check-inert-api-claims.pygrew a third arm,STALE_INERT_CLAIMS, that fails any line naming@debounceor@throttleand asserting it does nothing — scoped to the claim, not to the use site, so it reports 3 lines rather than the ~230 an@optimistic-style use-site match would. Gate-off: restoring each of the three sentences turns the checker red, 3/3. New cases intest_the_checker_catches_a_stale_inert_claim_about_a_wired_decoratorandtest_the_stale_inert_arm_does_not_fire_on_correct_prose.
Changed
CI shards balance on the runner's own timings, and the scoreboard headline moves from one command (#2584, #2615, #2526).
.test_durationsdecides how CI deals its fourpython-testsshards, and it was recorded on a 12-core Mac. On run 34173511325 the py3.12 shards took 182/235/584/204s of pytest against 280/278/328/193s recorded — per-shard slowdown factors of 2.6x to 7.1x, so the recorded imbalance read 1.70x while the runner's was 3.21x and the longest shard ran 584s against a 301s ideal. Every shard now records what it measured (--store-durations --clean-durations) and uploads it;scripts/merge-test-durations.pyunions the four disjoint files andmake test-durations-from-ci RUN=<id>closes the loop. The split also moved to--splitting-algorithm least_duration: the default cuts four contiguous runs and cannot separate two adjacent heavyweight files (it dealt one shard 204 tests, 186s of which was half of a single file), and bin-packing turns 280/278/328/193s into 270/270/270/270s on the same durations. The upload step is ancillary andcontinue-on-error; the gate is unchanged. New pins intests/test_ci_python_test_shards.pyandtests/unit/test_merge_test_durations.py, and the existing balance guard now collects under the algorithm CI actually uses.scripts/run-django-template-suite.py --write-baselinenow also retargets everydjango-suite-claimline inREADME.mdanddocs/TEMPLATE_BACKEND.md— percentage and the<ok> of <ran>counts, which were pinned by nothing — so the three places the headline lives move together from one measurement (--no-doc-claimsopts out). New cases inTestDocClaimRewriteandtest_doc_counts_equal_the_baseline.docs/TEMPLATE_BACKEND.mdnow states how to resolve a baseline conflict (re-measure against the merged tree, never pick a side) and why a silently stale baseline can only under-claim: an overstatement fails the blockingcompareon the nextmainpush..test_durationsin this release is recorded on the runner: 27,263 tests, 3,105s (mean 114 ms) against 1,094s for the same suite on a 12-core Mac. Measured effect on the py3.12 pytest step: 182/235/584/204s before, 293/289/407/420s after — the longest shard, which is the workflow's critical path, drops 584s to 420s (-28%) and the spread goes 3.21x to 1.45x. The residual is not the split's: the recorded balance is 1.00x, and xdist cannot divide the 205s slowest test, so the tail is now the binding constraint (#2723).Re-balancing surfaced two latent test-isolation bugs that were green only because xdist happened to deal them apart —
test_presence.py's fixture never cleared the Django cache thatCursorTrackeractually uses, and a{% load %}cache test seeded its plain entry before its own first (legitimately generation-bumping) parse, so it fails standalone on main today. Both fixed.CONTRIBUTING.mdrecords where the suite's time actually goes — the slowest 50 of 27,263 tests are 59% of the recorded time and one file is 35% — and that the scoped pre-push hook is a heuristic before the round-trip, not the gate..test_durationsregenerated, and the shard pin can now see staleness and imbalance (#2703). 21% of collected tests (5,573 of 27,118) had no recorded duration.tests/test_ci_python_test_shards.pypinned that the file exists, parses, and holds no foreign roots — none of which can fail when the file is merely out of date, the condition CONTRIBUTING says to act on past ~10%. Two guards now assert the outcome directly: the stale fraction, and the balance of the split pytest-split would actually produce. The balance guard asks pytest-split for each group rather than reimplementing its algorithm — the first version did reimplement it, disagreed, and passed green (#1859).This is a balance-quality fix, not a fix for the shard-3 CI failure it was found while investigating. Measurement refuted that theory: pytest-split balances by time, and the pre-regeneration shards were already even at 274/306/274/240s — the 9909-vs-1898 count spread is the intended consequence of grouping fewer, slower tests. #2703 tracks the real cause, still open.
Templates: a large
listor DjangoQuerySetin a template context is no longer converted at binding time (#2717). The #2695 review had exempted both from the conversion's decline at any length, because their declined spelling was wrong —{{ rows }}over a 100 001-row queryset rendered djust's own identity dicts, and{{ rows.0 }}answered''. That exemption cost 4 GB: on an unevaluatedUser.objects.all()over a real 150 000-row table,{{ v|length }}peaked at 4 437 MB / 13.4 s and{{ v.0 }}at 2 982 MB / 13.3 s. Both defects are fixed at the sink instead —{{ v }}/|pprint/|json_scriptspell the container from the live handle through the sameconsume_live_itemswalk{% for %}uses, and_SidecarQuerySetProxygained the__getitem__the live walk needs — so the exemption is gone and nothing is exempt for its length. The same cells now peak at 568 MB / 9.7 s and 149 MB / 0.9 s, with every rendered byte unchanged either side of the cap. New cases inTestTheContainerSinksSpellFromTheLiveHandle,TestNothingIsConvertedUntilASinkAsksForIt,TestTheSidecarQuerySetProxySubscripts,TestTheFloorHoldsOnEveryShapeTheDeclineNewlyClaims,TestAContainerThatSpellsItselfIsNotRespelledAsAListandTestTheContainerSpellingCallSitesAreTheSetNamed.
Fixed
A callable in a LiveView's context now resolves by Django's rules instead of rendering
None(#2621, ADR-027).normalize_django_valuereplaced ANY callable withNonebefore the context reached Rust — a "safety net" that pre-dated the resolution sink — so on the LiveView path{{ callable }}and{{ var.callable }}renderedNonewhere Django renders the call's result,{{ k }}on a class renderedNonewhere Django instantiates it, and alist-subclass class fell through to the pre-ADR walk's unguarded string-keyget_itemand rendered atypes.GenericAliasspelled with the string key. The plain path, which has nonormalize_django_valuein front of it, already answered Django's bytes for all five — the divergence was the arm, not the sink.The arm is now gated on
template_resolve_lazy: under ADR-027 a callable crosses raw andwalk_live's rootmaybe_calldecides, which is where Django's rules already live —alters_datarendersstring_if_invalidwithout running the mutator,do_not_call_in_templatesrenders the object as is, an arity mismatch renders empty, and a raising body propagates (#2506 never fails open). Normalization itself never invokes what it carries.Two boundaries keep the arm, and both are pinned:
state_roundtrip=True— the session and signed-snapshot channel, written by an encoder-less serializer that cannot hold a live object, so a callable in public state still persists asNone— and a callable the conversion does not model as aValue::Encoded, which has no handle for the sink to walk. The ADR-027 escape hatch (template_resolve_lazy=False) is byte-identical to before.Unchanged, and pinned so it is not read as wider than it is: a callable assigned as a public LiveView attribute still never reaches this arm. The public-attribute walk drops it first (
mixins/context.py), deliberately — without that, every@event_handleron the view would become a context variable — so{{ cb }}forself.cb = fnrenders empty on both settings. This change is about the context dict.This closes the last of the six cells ADR-027 movement 3 left held. Both stated sets in the characterization net (
PLAIN_WRONG_UNDER_LAZY,LIVEVIEW_WRONG_UNDER_LAZY) are now empty: rows J, J2, Q, P and P0 move here, row O closed in #2665 viaEncoded::display_safeand row V in #2613. New cases inTestTheArmsTruthTable2621,TestTheSinkAnswersDjangoOnTheLiveViewPath2621,TestNoLiveObjectReachesAChannelThatPersists2621andTestTheQuestionHasOneStatement2621;TestCallableintests/unit/test_normalize_django_value.pynow states the two channels that still drop. Django's own template suite is unmoved at 98.57% engine / 98.97% whole, with zero per-test OK→FAIL transitions.Also
#1646: the guarded_rust.crosses_as_encodedcall moved out ofnormalize_django_value's final fallback into a module-level_crosses_as_encodedhelper, because two arms now ask that one question and a second copy would drift on the first widening.TestTheGateHasOneStatementinpython/tests/test_opaque_collections_2477_2489.pyis restated to match: what must stay singular is the export, not the number of arms that consult it, and the pin is now spelled_rust.crosses_as_encodedbecause the bare name is a substring of the helper's and silently counted 1 → 4 at the refactor. Its new siblingtest_the_helper_has_exactly_the_two_arms_that_should_ask_itpins who asks.{% cache %}no longer renders its body on a cache HIT (#2658). The block-handler protocol renders the children before the handler is called, so the tag returned the stored fragment — correct output — after paying for the render it exists to avoid: correct and pointless. Block handlers may now declareLAZY_BODY = Trueand implement two phases instead ofrender()—before_body(args, context) -> (str | None, state), asked before the children render and able to answer outright, andafter_body(args, content, context, state) -> str, reached only when the first declined. Two phases rather than a re-entrant callback, which would have Python calling back into the renderer while Rust holds&mut Context; the opaquestateis carried across the body render so phase two need not redo phase one's work. Args are resolved and the context snapshotted beforebefore_body, and both phases get that same snapshot — Django's own order, sinceCacheNode.renderresolves, keys and consults the cache before touchingself.nodelist.Existing single-phase handlers are untouched.
LAZY_BODYwith either phase method missing, or combined withRETURNS_BINDINGS/WANTS_AUTOESCAPE, is refused atregister_block_tag_handlerrather than half-honoured at render time; a lazy handler needs norendermethod.The behaviour change is the fix: a body with side effects (a
{% cycle %}, anas-binding, a lazily-evaluated queryset) stops running on a hit, because in Django it never ran. That also closes the{% cycle … as k %}divergence #2658 reported as a separate scope bug — djust now emits Django's exact<1>1|<1>1|<1>1|. New cases inTestTheBodyDoesNotRenderOnAHit, which replaces theTestTheBodyStillRendersOnAHitthat pinned the gap, and inpython/tests/test_lazy_body_block_protocol_2658.py.reassert()re-registered{% cache %}as an inline tag (#2658, #1646).djust.test_isolationrunsreassert()before every test, and it dispatched onisinstanceof the two generic library handler classes — soCacheTagHandler, which is neither, fell to the inline branch:cachewas re-registered as an inline tag and its block handler unregistered, and{% cache %}…{% endcache %}stopped parsing as a block for the rest of the worker. It now makes the same_BESPOKE_BLOCK_TAGSdispatch_bridge_bespoke_block_tagmakes. Pinned byTestReassertKeepsCacheABlockTag.Templates: a sized sequence is no longer materialised when it enters a context, so
range(10**9)renders instead of hanging (#2695), anddictsort/dictsortreversedconsume a one-shot iterator through the same sink{% for %}and|joinuse (#2693).#2678 stopped the conversion enumerating an object whose stated
__len__its__getitem__could never reach, but scoped the decline to that one shape — sorange(10**9), which terminates, was still read a billion times the moment it entered a context and{{ v.0 }},{{ v|length }},{{ v }}and{% for %}all failed to return. Django never enumerates at binding time, so the bound now sits on the stated length for every sized sequence whose items do not already exist, and each sink reads what it needs off ADR-027's live handle:|lengthislen(v),{{ v.0 }}/|first/|lastarev[i],|sliceisv[slice(*bits)],inis Python's own__contains__and{{ v }}isstr(v)— all in constant time, all Django's own answers.A
listand an evaluatedQuerySetare exempt from the decline at any length, because declining them produced a WRONG SPELLING: a 100 001-row queryset rendered{{ rows }}as 66 MB of djust's own serialization dicts where the same queryset one row shorter renders[qs0, qs1, qs2], and{{ rows.0 }}answered''. Not a floor breach — the rows are denylist-filtered on both sides — but a content and payload change, now pinned on both paths a queryset reaches the engine by.That exemption is a TRADE and the price is not zero: asking a
QuerySet's length sinks_fetch_all()(~140 MB of rows) but NOT the conversion of those rows into values, so on an unevaluated 150 000-row queryset{{ v|length }}peaks at 4 650 MB with the exemption against 593 MB without it. It is taken because the declined spelling is wrong and because the cost is not new — neither shape was ever declined before #2695 either. Getting both — decline the carrier AND spell it correctly at the sink — is #2717.The cap moved to where it belongs rather than being removed: the conversion asks "is the stated length too large to spend here" and the SINKS ask "can this walk end at all", so
collections.deque(range(100_001))still renders every item at{% for %}while #2678's liar still raises.{% for %}/|join/dictsortover a stated billion still does not return — measured with a 60 s deadline and a 4 GiB ceiling, neither does Django's.dictsorthad iteration of its own instead of going throughfilters::iter_values, so it answered''for a generator, amap, azipand a one-shotiter([...])where Django sorts them. A structural test now pins the SET of functions allowed to match a sequence by hand, and requires every suchmatchto nameValue::EncodedIN THAT MATCH — the per-function version of that check was satisfied by an unrelated line and could not fail.Verified by a Django 5.2 differential over 12 sinks x 19 shapes x both
template_resolve_lazysettings, inTestTheDjangoDifferential: on the shipped lazy default 192 of 218 comparable cells render Django's string byte for byte and the remaining 26 are pinned as a set, with 148 of 204 on the eager escape hatch and 56 pinned there.Test fixtures no longer make macOS write a crash report per run (#2701).
test_django_template_suite_2517.py's crash-isolation fixtures had their childos.kill(os.getpid(), signal.SIGSEGV)to prove the scoreboard runner isolates a native death and resumes. The death is deliberate and the runner handled it correctly — but SIGSEGV/SIGBUS/SIGABRT are fatal-exception signals, so macOS ReportCrash wrote a full.ipsreport to~/Library/Logs/DiagnosticReportsfor every one (~37 in a day), which buries a real crash in fixture noise.The runner branches on
returncode < 0and formats-returncodeinto its message; it never inspects which signal, so the choice is free. The fixtures now useSIGKILLon Darwin — same branch, no report — and keepSIGSEGVelsewhere, so CI's closest-to-native-death coverage is unchanged. SIGKILL is the stricter death besides: uncatchable, so faulthandler never runs and the child cannot flush buffered output, meaning the quiet local runs are the ones that would catch a runner whose finished-test accounting depended on a dying child's cooperation.test_no_reported_crash_signals_2701.pypins it, scoped to the sending side so naming these signals in a detection set or a comment stays legal. Its own canary caught the first version of its regex failing to matchos.kill(os.getpid(), …)—[^)]*?cannot cross the)inos.getpid(), so the pin would have missed the exact line it exists for.Templates: a non-
listsequence is spelled as ITSELF at every length, so{{ v }}over arange/deque/array/bytes/ sized user class renders the container's ownstr()as Django does (#2704), and{{ block.super }}renders the parent when the expression is EVALUATED rather than when the block is entered (#2710).Value::List's display is a list repr, so every object PyO3's sequence extraction claimed rendered{{ v }}as[3, 1, 2]where Django rendersrange(0, 3),deque([3, 1, 2]),array('i', [3, 1, 2]),b'\x03\x01\x02'or<X object at 0x…>.|sliceinherited it, Django's filter being a barevalue[slice(*bits)]passthrough. #2695 had made the divergence LENGTH-dependent rather than uniform — pastOPAQUE_ITEM_CAPthe same shapes decline into ADR-027's carrier and do renderstr(o), sorange(3)was[0, 1, 2]whilerange(10**9)wasrange(0, 1000000000). The conversion now asks the spelling question at every length instead. Alistkeeps the list arm because its ownstr()IS the list repr; the eager escape hatch keeps it because there is no live handle to fall to (a decline there lands onstr(o)and{% for %}walks the repr character by character); a DjangoQuerySetkeeps it because declining it is #2717's half, not this one's.Two subscript sinks followed.
Encoded::live_get_itemandlive_get_sliceeach refused a carrier that held items, as a stand-in for "not subscriptable" that was true only while the sole item-holding carriers were aset/frozenset/dict_keys. Adequenow holds items AND subscripts, so{{ v|first }}answeredTypeError: 'deque' object is not subscriptablewhere Django answers3. Both now ask the live object, through one predicate — which also gives{{ p|first }}over amappingproxyDjango's ownKeyError.For
block.super, static detection that a child body MENTIONS it is not evidence that evaluation will reach it, so a reference inside a false branch ran the parent anyway — measured against Django 5.2.16,{% if show %}{{ block.super }}{% endif %}childwithshowfalse called the parent once here and zero times there, and a parent that raises only when asked turned an otherwise-fine false branch into a 500. Nine more cells diverged the same way, and two the issue did not name: Django does NOT memoize (BlockNode.super()is a method, so two references render the parent twice and a{% for %}over three items renders it three times — a parent containing{% cycle 'a' 'b' %}referenced twice isa-bthere and wasa-ahere), and{% if show and block.super %}/{% firstof yes block.super %}short-circuit past it.The scope now carries a deferred source — the parent nodes plus an owned loader handle, since the resolver holds
&Contextand cannot borrow the render arm's loader — andContext::resolveruns it on each request. That is the one resolver{{ }},{% if %},{% with %}and filter arguments all end in. The one boundary it cannot cross is a PYTHON-BRIDGED tag, which receives the context as a flat map that Django's own code then resolves against ({% blocktranslate with s=block.super %}); all seven builders of that map go through one materialiser, which preserves the previous behaviour there exactly.Verified by two Django 5.2.16 differentials. The sized-sequence grid (12 sinks x 19 shapes x both
template_resolve_lazysettings) gains ten cells on the lazy default and loses none, with the eager set byte-identical; the opaque-collections grid gains the twomappingproxysubscript cells and sweeps four newly-carried shapes against Django for the first time; and theblock.supergrid compares 26 cells on OUTPUT and parent-evaluation COUNT, of which 10 diverged before and 0 after. New cases inTestANonListSequenceIsSpelledAsItself2704and inpython/tests/test_block_super_lazy_2710.py.Flush pending handler and element rate limits through HTTP keepalive during page teardown and WebSocket close, coalescing edits to the latest payload. The abnormal-close flush is an out-of-band POST that races the client's own reconnect: it is never applied to the DOM, and in the losing ordering its session write is simply overwritten, so the edit is preserved or lost exactly as before — never corrupted. Reset rate-limit, cache, and optimistic configuration on the PAGE view's own mount so old-view timers and rules cannot affect the next view; lazily-hydrated sibling views and the
mount_batchfallback mount additively on the same socket and no longer wipe the page view's configuration (#2721).Retain the same lazy block-tag handler across body rendering, so registry replacement or removal cannot deliver phase-one state to a different handler.
Template loader containment and inheritance: Reject Python backend template paths outside configured roots and remove the obsolete Python inheritance algorithm, retaining integration coverage through native rendering.
Template engine isolation: Keep bridged tag/filter registries, library state, and source/include cache entries scoped to their backend. Publish compiled templates and validation generations together and test stable cache reuse after library loading.
Template AST traversal: Share exhaustive immutable and mutable child enumeration between inheritance annotations and model-field discovery. Discover literal model bindings inside ifchanged branches and inherited parent scopes while preserving scope-specific renderer behavior.
Documentation
Correction to two scoreboard figures published in
[1.2.0rc2](#2619). Two bullets in that section were written before thechangelog.d/convention (#2598) and carry conformance deltas measured against a baseline that moved before they merged. The[1.2.0rc2]section itself is left byte-identical to its tag (#2028 — a shipped section is immutable); the corrected figures are recorded here instead.{% autoescape %}(#2556 PR A). The bullet claims44.03% → 50.91%of engine cells (461 → 533 of 1047) and71 OK / 6 FAIL / 2 ERRORfor the tag's own share. Re-measured on the merged tree — the arm gated off and per-test outcomes diffed — the 79 cells refused asUnsupported template tag '{% autoescape …'go 74 OK / 5 FAIL / 0 ERROR, and the whole scoreboard moved 55.68% → 62.85% (583 → 658 of 1047), 68.13% → 73.28% over the whole label. The same[1.2.0rc2]section already carries these numbers in the#2556merge bullet, which said in-line that they supersede the earlier ones; this entry states the correction on its own so a reader meets it without having to reconcile two bullets.The five built-in tags (#2556 PRs B–E). The bullet claims
44.03% → 48.52%(461 → 508 of 1047). No re-measure of that branch against its merged base was ever recorded, so there is no corrected delta to publish and none is invented here: read it as measured against that branch's own base, before #2593, #2594, #2596 and #2607 landed around it, and therefore not comparable with the release's other figures.The anchor that is comparable. On the tree released as
1.2.0rc2,make django-template-suitereports 98.57% of the engine subset (1032 OK / 1 FAIL / 14 ERROR of 1047) and 98.97% over the wholetemplate_testslabel (1441 of 1456). Per-PR deltas in this changelog are point-in-time measurements against whichever baseline that PR branched from; #2615 tracks the shared-baseline problem that makes them drift.