Repository navigation
v1.3.0rc16
Pre-releaseAdded
LIVEVIEW_CONFIG["http_status_of_state"]sets the not-found/gone status map for the whole project, and V025 warns about aget_http_status()override that would replace it (#3706).http_status_of_state(#3688) was a class attribute only, so a project with several base views repeated the map on each. A view that declares none now reads the config key; the view's own attribute wins, then the config, and the two are never merged (a view that declares{}opts out of the project's map; neither set answers 200 as before).LiveView.http_status_of_stateis now an empty read-only mapping that compares equal to{}, so a view's own declaration (even{}) is told from "declared nothing" by identity. The key is validated like the form-data limits: when the config loads and on everyconfig.set()/config.update(), it must map state names (str) to statuses (int, 100 to 599); anything else is logged and replaced by no map. V025 (Warning,manage.py check) reports a class that definesget_http_status()while a project-wide map is configured, once per defining class, so per-view copies of the logic do not creep back; suppress it withDJUST_CONFIG = {"suppress_checks": ["V025"]}for an override that callssuper(). The other items of #3706 (V023params_formcheck, the test client drivinghandle_params, the reserved names) shipped in #3722. Not included: lettingmount()raiseHttp404/PermissionDeniedand render the view's own template under that status on every transport, and a per-field fallback forhandle_invalid_params(#3874).self.server_push(),self.live_transportandtick_intervalover SSE (#3809). A view can now push from its own code on any live transport:await self.server_push("handle_x", {...})runs the samepush/server_pushturn the WebSocket runs forpush_to_view()(turn chain, render lock, busy deferral in arrival order, one render, asource="broadcast"frame) on a WebSocket or an SSE session, and is a documented no-op returningFalseon the HTTP render, an HTTP fallback POST's fresh instance,LiveViewTestClient, and a closed or replaced view.self.live_transport("websocket","sse"orNone) answers "is a live transport attached?" and is already set insidemount().tick_interval+handle_tick()now tick over SSE (they stood still), stopped by a close, a navigation or a failed mount.push_to_view()groups, push scopes anddb_notifystay WebSocket-only over SSE; documented inserver-push.mdand thesse.pylimits.live_transportis a reserved name (a read-only property; assigning it in a view raisesAttributeError). Over SSE a tick or push re-checksreauth_on_eventagainst the stored session and user, and server-driven frames are bounded (a tick is skipped and a push deferred while 128 frames are queued unread). Slice 1 of #3809.params_form_fallback = "fields"keeps the fields that validated whenparams_formrejects the URL (#3874). The defaulthandle_invalid_params()handshandle_params()the initial value of every field, so?page=abc&sort=namelost the validsort=namewith the invalidpage. A view that setsparams_form_fallback = "fields"keeps every field that validated (it is not inform.errorsand is inform.cleaned_data, filled frominitialwhere the URL left it out, exactly as on a valid URL) and replaces only the failed ones with their initial value; a failed field's own entry is always its initial value ("initial"is the mode that never hands over a raw request value; aclean()that copies raw request data into a kept field hands it over through that field, as on a valid URL for a field the URL supplied). A form-level error (clean()raising without naming a field) and a URL past the form-data bounds keep nothing, because the fields that passed one by one are the combination the form refused (aclean()that names the field withadd_error()is a field error and keeps the siblings, provided the merged result still passesclean(): it is run once more on a copy of the form with the kept values next to the replaced fields' initial values, and the view gets the plain initial values ifclean()rejects it, so the result is always a combinationclean()accepts, or the plain initials; the fields' ownclean_<field>()validators are not run again on the merged values).params_errorsis unchanged. The default does not change:"initial"stays what a view gets unless it opts in. A value other than"initial"or"fields"raisesImproperlyConfiguredon every request of a view that declaresparams_form, andmanage.py checkreports it as V023, so a typo such as"field"cannot silently mean the default. Guide: Navigation, "Keeping what validated".clean()must be idempotent to use"fields": it is re-run on values it already cleaned (aclean()that multiplies a price by 100 handshandle_params()50000 where a valid URL hands 500), and its output is adopted only for the fields that were kept, so a replaced field is always its initial value.scripts/gate_off_batch.py/make gate-off-batch: a gate-off over many sites in one full test run per batch, not per site (#3905). It applies every mutation of a batch at once, runs the whole test command once, and attributes each failing test to a mutation by the text of its failure (a declarednamessubstring or atest_idsregex). A test claimed by two mutations, a failure nobody claims, a batch that does not collect, hangs or runs away is never guessed onto a member: the affected mutations are re-run alone. It reusesscripts/gate_off.pyfor the bounded run (HUNG and RUNAWAY-MEMORY areCAUGHTwith the measurement), the directory lock and journal, the SHA-256-verified restore and pytest classification (errors counted; a module that did not import isINVALID), and adds a clean-tree guard,--survivors-only,--verify-survivorsand--jobs 2. Documented indocs/testing-strategy.md; the tool is itself gate-off'd inscripts/gate-off-batch-selfcheck.json.
Changed
-
A Django
Form,BoundFieldor formset crossing to the Rust engine as an object is no longer rendered until the template prints it (#3769). The conversion of a form object calledstr(form)(theas_divrender of every field) andstr(bf)for every field a loop produced, on every render, for every form in the context, whether or not the template read it; a template that never touched the form paid 4-5x the cost of the flattened dict it replaced (#3755).Encoded.displayis now aDisplayText: eager for every other object, and for a form /BoundField/ formset an object that is asked forstr()the first time a sink reads it ({{ form }}, a string filter,|json_script, a loop-cache hash), with the answer shared by every clone of the value. A form printed N times is rendered N times, as under Django; the eager conversion rendered it once before the template printed anything (throughDjustTemplateBackend.from_string(..).render({"form": f}):hellocalledstr(form)once and{{ form }}twice, now 0 and 1) and once more per occurrence.display_safe,str_raisedand (for every opaque carrier, where it was a copy ofdisplay)jsonare derived from that one reading and are methods (Encoded::display(),display_safe(),str_raised(),json()), so a new reader of the text cannot get a half-built answer; the walk floor is unchanged (the asked object is still the floored handle), and the teardown that clears a view's live handles (clear_live_handles) releases the text's own handle too, without running the object's__str__: a text nothing had asked keeps itsrepras the stand-in, so a render of the state AFTER that teardown prints thereprwhere the eager text printed the form (the teardown runs at disconnect and a reconnect re-converts from Python first, so no connected view renders through it; pinned bytest_a_render_after_the_clear_shows_the_stand_in_not_the_form), while a text that was asked keeps its answer. A form whose__str__raises is now an ordinary carrier whose text raises at{{ form }}and nowhere else, as under Django. Behaviour change, towards Django:str(form)validates the form (full_clean()), so reading it at conversion validated every form the engine was handed. Spellings that depend on that accident now print what Django prints for a form nothing validated:{{ form.cleaned_data }}is empty (it was the cleaned data),{{ form|pprint }}saysvalid=Unknown(it saidvalid=True/False), and on a boundModelForm{{ form.instance.x }}is the unmodified instance (it carried the submitted value). A view that callsform.is_valid(), or a template that readsform.errors/form.is_valid, is unaffected. Cost, measured on a release build (CPU time per render,DjustTemplateBackend, a bound invalid form of NCharField(help_text=...)): a template that never reads the form 734 -> 33 us at N=4, 3,240 -> 39 us at N=20, 30,035 -> 97 us at N=200;{{ form.f0 }}796 -> 131 / 3,401 -> 141 / 30,021 -> 312 us;{{ form }}is now Django's own cost (1.9x faster than before, the same as Django's engine). Not removed: a loop over a large form still pays, perBoundField, the walk floor's re-derivation of the form's password-widget fields (serialization._model_form_floor), which is O(fields) and so O(fields^2) over a loop; it is floor code and was left as it is. Known, never worse than before: a form (or a list/dict of forms) passed through|join,|unordered_list,|safeseq,forms|joinor{% regroup forms %}is still read eagerly where Django does not read it; those five spellings render the form's text, as they did. -
A template path asks "is this a password field?" by NAME, so a loop over a wide form is no longer quadratic (#3816). The password-widget floor of #3798 built the form's whole set of password names, scanning every field, for every
BoundFielda template walked: a{% for f in form %}over a 100-field form was 100 walked fields x 100 scanned fields per pass, +35-50% of a loop-heavy render (5 fields +5-10%, 30 fields +2-7%). A bound field of a plain form is now decided by_password_name_held(fields, name), which is one dict lookup per way of splitting the name into<field><suffix>(so aMultiWidget'spair_0/pair_1keys are still held), and never builds the form's floor; aModelForm's floor carries the names as a read-through view of the form's livefieldsinstead of an eager set. Same answers (a randomized sweep against the scan covers plain, subclass, drawn-as-password andMultiWidgetforms with indexed, keyed and empty-keyed parts), with nothing remembered: it is not a memo, so a widget swapped on a live form (text -> password -> text), a field added, replaced or removed, or a widget'sinput_typechanged in place is followed on the very next step, and no answer can be shared between forms, requests or threads. Release build, loop-heavy templates, interleaved A/B (minimum of 4 rounds each): 100 fields 30.3 -> 17.4 ms (one loop) and 89.2 -> 37.7 ms (three loops), 30 fields 6.5 -> 5.4 ms and 15.9 -> 11.8 ms, 5 fields 1.15 -> 1.13 ms and 2.50 -> 2.40 ms (within noise). A formset's floor, which unions the password names of every form it built, is unchanged (20/100/300 forms showed no measurable difference).Long keys.
form.datais keyed by what the client posted, and the by-name check is a lookup per way of splitting the name, so on its own it is quadratic in a key's length (and cubic whenform.datais filtered, which asks about every--delimited tail of every key): an 8 KB key of dashes pinned a worker for about 30 s. Two guards keep a key's length from deciding how often the form is asked:_without_floor_keysreads the names as a SET once per mapping (as before this change, linear in the key), and_password_name_heldanswers a name past 256 characters with one scan of the form. Measured on a boundUserChangeForm,{{ form.data.username }}with one extra POST key (release):"-" * 30006.2 ms (1,726 ms without the guards, 6.5 ms before #3816);"-" * 800020.9 ms (29,576 ms; 20.7 ms);"a-" * 400012.3 ms (15,038 ms; 12.2 ms).Regression cases in
TestAKeyAClientChoseCostsLinearInItsLength(a count of lookups and scans on 10,000-character plain, dashed and prefixed keys, with exact agreement with the old set),TestAStepDoesNotScanTheForm(afieldsmapping that counts every whole-form read: a step of a 100-field form scans nothing, and the scans of a native loop are the same for 10 fields and for 100),TestAWidgetSwapOnALiveFormIsFollowed,TestNothingIsSharedBetweenFormsandTestSameAnswerAsTheScaninpython/tests/test_password_names_by_name_3816.py. -
The form-data size limit (
max_form_data_size) is now exact, no member is free, and an event weighs the form's stored data in C passes rather than a Python step per member (#3841). The size is the characters of the keys and values the data would hold after the event, with no extra character for the data itself, and a size equal to the limit is accepted (before, each check added one for its own container, so an event could count two too many and a payload of exactly the limit was refused when nothing else was kept). Behaviour change: an empty string,bytesorbytearraynow counts as one character, not zero. Before,[""] * 15000(about 60 KB of JSON, under the default frame limit) weighed a single character, so 150 suchvalidate_fieldevents stored 2,250,000 empty members and made every later event take over a second; now the size limit bounds how many are kept (a form that declares N fields holds N more characters for their empty initial values). A flat list of containers is no longer pulled whole before it is charged. With the form filled to the cap an event cost 174-279 ms to weigh; it is now 4-30 ms (a list of 320,000 integers: 148 ms to 5 ms; 300,000 integer-valued fields: 200 ms to 13 ms), and an event that replaces a field no longer copies the rest of the data to find out what is left. The verdict is unchanged for every other input (checked against the previous walker on generated values and event sequences). -
A native component handler uses an explicitly passed argument as given, even when it is empty.
data_table(rows,columns),breadcrumb,stepper,dj_select,dj_radio,autocompleteanddependent_selectnow let an explicit argument win over a context variable of the same name; the render context is consulted only when the argument is absent (the handlers' documented convenience, unchanged). An argument that resolves to nothing counts as passed:{% breadcrumb items=nope %}draws empty, as the Python tag does, where it used to fall back tobreadcrumb_items. A structural test scans the handler modules for the previous shape.
Fixed
-
The native handlers of nine component tags dropped or invented
dj-*event attributes the Python tag emits (#3669). Under the socket render a click or input handler wired by the first, Django-engine render was missing or renamed. The nine tags, in six families:dj_button;color_picker;pagination;data_table;nav_item;filter_barwithfilter_select,filter_date_rangeandfilter_search.dj_buttonkeptdj-clickonly while enabled, never emitteddj-confirm, and ignoredpreset=(a preset can carryevent/confirm);color_pickerignoredswatches=;paginationdrew no numbered page buttons (each firesnext_eventwith the page asdata-value);data_tableignoredrow_click_eventand theprev_event/next_eventpager shown whentotal_pages > 1withoutpaginate;nav_itemwith aneventand a text body became a dropdown with nodj-click;filter_bartook each control's owneventand never drew the "Clear filters" button. The Python tag is the reference. Forfilter_bar, a native block handler is handed only the rendered children, so eachfilter_*child now leaves its arguments in a comment that the bar reads, removes and draws with the Python tag's own renderer (render_filter_bar), which ownseventandclear_event; a child outside a bar leaves an invisible comment where Django renders nothing; the default id staysfilter-barrather than a per-render random one. Whether a value wires an event is decided on the VALUE, not on its escaped text, soconfirm=None/False/0/[]androw_click_event=Nonewire nothing, as in Python.python/djust/tests/test_component_dj_attr_parity_3669.pyrenders about 95 argument combinations of the twelve tags in the issue through both engines and compares the(element, dj-attribute, value)multiset, plus every falsy shape (None,False,0,0.0,"",[],{}) for every event-bearing parameter, a hostile event-name sweep (quotes,<,&,</script>, U+2028) and a strict-CSP check on both engines.comboboxandtoast_containeralready agreed and are now pinned. Behaviour that stays different, and why:tree_view/kanban_boarddiffer only for aSafeStringoperand (native escapes it, the safe side); the nativedata_tablealso wires about a dozen features the Python template never renders; the native bar and nav item see rendered children, so controls and sub-items inside{% if %}/{% for %}, a bar nested in a bar, and other content in a bar are drawn natively (as before) where the Python tag drops them; the string"0"and a lone space reach a handler as the number 0 and nothing (the engine hands handlers text); a block tag's unresolved-variable argument; anav_itemnested three levels deep; and the Pythondata_tabletemplate's ownsortable: Falsequirk. Markup outsidedj-*(classes, text, ids, ARIA) is #3663. -
The Rust
Avatarread a URL with a leading control character as having no scheme (#3748).url_scheme()incrates/djust_components/src/simple/avatar.rsremoved only tab, CR and LF before it looked for the scheme, so"\x01blob:...","\x00file:..."and"\x1fintent:..."failed the leading-letter test and reached<img src>as written, whereurl_attrreturns#. It now strips leading C0 controls and space first, as Python'surlsplitand a browser do. The recorded gap (KNOWN_RUST_MIRROR_GAPS) in the sweep test is gone, the sweep corpus gained leading-controlblob:/file:/intent:payloads, andpython/tests/test_avatar_rust_scheme_parity_3748.pycompares the Rust avatar withurl_attrover every C0 control in every position and a seeded random sweep. The markdown renderer's three-scheme denylist is unchanged; the measurement is on the issue. -
A Django
Form/BoundFieldbound by{% for %}or{% with %}renders under the Rust engine as it does under Django (#3755).{% for f in form %}{% field_error field=f %}{% endfor %},{% with f=form.name %}{% field_error field=f %}{% endwith %}and{% for f in form %}{{ f.errors }}{% endfor %}rendered nothing under the Rust engine and the errors under Django. The plain-backend render path flattened a form to{name: SafeString(widget HTML)}before the engine saw it, which keeps{{ form.name }}working and loses what a form is: iterating it yielded the field names ({{ f.label }}was empty),f.errors/f.field.requiredread attributes a string does not have,form|lengthwas the number of fields (Django: 0) and{{ form }}printed a dict. Withfor_render=Truethe form now crosses as itself (template.serialization.serialize_value); the JSON-state paths (normalize_django_value, the LiveView bridge) still flatten. Four engine rules came with it, each measured against Django: (1) a bridged Python tag receives a liveBoundFieldalthough aBoundFieldis iterable (context::live_handle_is_form_object, decided bydjust.serialization._is_form_object). This is a new spelling of a reach that already existed, not a new reach: a bridged tag already got the live form under its own name, and the bridge has no serialization floor, so{% some_bridged_tag x=f.form.instance.password %}now prints whatx=form.instance.passwordalready printed (#3771; the PR's first version of this line said "adds no exposure", which was wrong forjson_viewer, since fixed by keeping it on the floored native path, #3756). (2) Alistsubclass that overrides__str__/__repr__(Django'sErrorList) crosses as the carrier whose{{ v }}isstr(o)(list_subclass_spells_itself, the list half of #2899), so{{ f.errors }}is<ul class="errorlist">…</ul>and not['This field is required.']; for|json_scriptthe carrier is the array, as the dict-subclass one is the object (Encoded::live_sequence_value), so{{ form.errors|json_script:'e' }}is the errors as JSON. (3) A top-levelErrorList/ErrorDictin the context is no longer rebuilt as a plain container byserialize_value(limited to subclasses that define__html__; a top-levelOrderedDictis rebuilt as before). (4) An object whosestr()/iter()raised at conversion stays iterable by TYPE, so{% for f in form %}over a form with one broken widget renders the loop and the real exception appears where the broken field is rendered, as in Django, instead of'Form' object is not iterable. AForm/BoundFieldis also carried like a one-shot iterator (handle live, items read at the sink), which removes the eager subwidget enumeration. Behaviour change:{{ errors }}for a top-levelErrorList/ErrorDict, and anylistsubclass with its own__str__/__repr__reached through an object, now print what Django prints (the errorlist markup) instead of a list / dict display. Cost: a form in the context of a template rendered throughDjustTemplateBackendcosts more per render than the flattened dict did (0.86 ms against 0.31 ms for four fields, 3.5 ms against 1.3 ms for twenty, release build), because the engine converts an object eagerly,str(form)included; the LiveView path is unaffected; tracked at #3769. Not covered here: a form held as LiveView state still reached the template as{name: widget HTML}, because LiveView state is JSON (fixed in #3767). -
{% json_viewer data=v %}renders adatetime/date/Decimalas Django does (#3756). The Python tag dispatches onisinstance(value, ...)and spells every type it does not know asstr(value)with no type modifier (<span class="dj-json__value">2024-03-15</span>); the native handler was handed the engine's stand-in for a value it cannot carry (str(datetime), afloatfor aDecimal) and dispatched on that, printingdj-json__value--stringwith quotes and--number. The same went for aUUID, anEnum, aPath,bytes, asetand atuple, and for adatetime/date/Decimal/UUIDnested in a dict or a list. A handler can now declareTYPED_ARGS = True(rust_handlers.TYPED_ARGUMENT_TAGS, read byregistry.rsat registration): the engine resolves eachkey=<expression>operand through the same floored walk{{ }}uses (get_value, which re-protects every segment of a dotted path) and hands the typed Python value to the handler in the context under a reserved key;json_viewerreads itsdatafrom there, with the operand's runtime-safe flag ({% with p=x|safe %}, amark_safed value) carried through the channel so a safe operand is raw as in Django (typed_operandsusesget_value_safe; without it the flag was dropped and the operand over-escaped; a hostile unsafe value stays escaped, tree and raw<script>copy, byte for byte against Django,python/tests/test_typed_args_provenance_3770.py). It deliberately stays native. A first version of this change moved it onto the{% load %}bridge so Django's own node could dispatch on the type, and the bridge walks raw objects with no serialization floor:{% json_viewer data=form.instance.password %},data=f.form.instance.passwordin a form loop,data=form.fields.owner.queryset.first.password,data=obj.user.password,data=obj.users.first.passwordanddata=view.user.get_session_auth_hashall printed the hash, which{{ }}and the native handler refuse. Every one of those is a test now (python/tests/test_typed_args_floor_3756.py). The tags that depend on a value's type are measured, not guessed (scripts/lib/template_library_parity/typed_values.py, 208 tags x 15 types): the Python tag is run on a value and on the stand-in the engine would hand a handler, and a tag whose output differs depends on the type. Of the 781 type-sensitive cells, the tags still wrong for a type a template realistically passes (datetime,date,time,timedelta,Decimal,Enum,Path) are four, all a date or path in a CSS-class / URL slot (theme_mode_toggle,theme_preset_selector,theme_switcher,map_picker); they are recorded inKNOWN_TYPE_LOSS, which the test compares with the measurement in both directions (an entry that stops being wrong fails; a new loss fails until it is looked at), and the remaining isolated cells (a tuple, UUID, bytes or set in a text or number slot, and a tuple holding anOrderedDict) are tracked at #3768. A tag rendered through the bridge still has no floor (#3771). -
A Django
Formheld as LiveView state reaches the template as the form, not as{field_name: widget HTML}(#3767).LiveViewstate is JSON, so_sync_state_to_rustflattened aBaseFormheld on the view to{name: SafeString(str(form[name]))}(serialization.render_form_value) and left it out of the raw-Python sidecar. That keeps{{ form.name }}and loses what a form is:{{ form.name.errors }},{% field_error field=form.name %}and{% for f in form %}{% field_error field=f %}{% endfor %}rendered nothing,{% for f in form %}{{ f.label }}iterated the field names,{{ form.is_bound }}and{{ form|length }}read a dict, and{{ form }}printed it, whileDjustTemplateBackend(since #3755) and Django rendered all of them. The cure is not one of the three options the issue lists, and it needs no Rust change. All three assume the form must also be in the Rust state (flattened, or as a handle-bearing value that is diffed, re-sent and serialised on every event). The engine already has a channel for a live object that is not state: the raw-Python sidecar, resolved lazily at the lookup, per segment, under the serialization floor. So a context name for whichdjust.serialization.holds_formis true — aForm, aBoundField, a formset, or adict/list/tupleholding one (up to four containers deep: a wizard'ssteps,{"login": ..., "signup": ...}) — is kept out of the state and rides the sidecar only, so the value stack has nothing to answer first. The same decision is read at every entry that hands the engine a state dict and the raw objects separately:_sync_state_to_rust(HTTP GET, WebSocket mount and event, SSE and the runtime all call it), the page shell (render_full_template, both branches) and atemplate_namecomponent. The set ofupdate_statecall sites is pinned from the source. Cost, measured on a release build (an event that assigns a new form and renders; min of 5 batches of 150; the figures are for 4, 20 and 60 fields): a template that reads a field ({{ form.f0 }},{{ form.f0.errors }}) goes from 1.2 / 2.1 / 4.6 ms to 1.0 / 1.2 / 1.4 ms, and one that never touches the form from 1.2 / 2.3 / 4.9 ms to 0.9 / 1.0 / 1.4 ms, because only what the template touches is converted and the per-field flatten is gone; an event that does not change the form costs the same as before (0.7-0.9 ms either way) for those templates only. The one thing that got slower is the whole-form spelling:{% for f in form %}{{ f }}{% endfor %}goes from 1.1 / 2.1 / 4.5 ms to 2.0 / 6.0 / 15.2 ms and{{ form }}from 1.1 / 2.3 / 5.1 ms to 1.7 / 4.5 / 10.4 ms. That is the engine's eager conversion of a form object (str(form), theas_divrender), the cost #3769 measures onDjustTemplateBackend(6.3 ms for the loop at 20 fields), and those spellings printed the wrong thing before (the loop yielded the field names,{{ form }}a dict). It is also paid by an event that does NOT change the form: the form is a sidecar object, so the engine cannot see that it is the same one and converts it again on every render that evaluates the whole-form node (measured by the review:{% for f in form %}{{ f }}0.8 / 0.8 / 0.9 ms before against 1.8 / 5.6 / 15.9 ms after at 4 / 20 / 60 fields,{{ form }}0.8 / 0.9 / 0.9 against 1.4 / 3.4 / 8.1; a probe of mine through the partial-render entry (render_with_patches) did not show it, a fullrender()did, so it depends on whether the node is re-evaluated). #3769 (a lazyEncoded.display, its PR in progress) is the cure and is unchanged by this fix. The floor holds on every route: the form crosses as the already-floored proxy (#3790, #3798), andpython/tests/test_form_floor_differential_3790.pynow runs its whole grammar (4998 templates in 213 groups) against a root form held as an attribute of aLiveView, asserting both that no part of the secret appears and that the render equals the plain backend's. That also closes a flattened-copy exposure the old shape had for theDJUST_SENSITIVE_FIELDStext field:{{ form|pprint }}and{% for k, x in form.items %}{{ x }}{% endfor %}printed the control'svalue="…"for a held form and a held formset, which the plain backend never did. What does not change: JSON state.get_state(), the session save and the signed snapshot usenormalize_django_value, which still flattens (state_roundtrip=True) and still treats a form attribute as the non-serialisable value it always was; a form restored from the session (enable_state_snapshot, the HTTP POST fallback) is therefore still the JSON dict. Behaviour change: a view that keepsformset.management_formdirectly as an attribute used to flatten it (no data readable); it now reaches the template as the form, a plainFormwith no pinned floor (#3799), so{{ mf.data }}prints the formset's whole submitted data, as on the plain backend (docs/SECURE_DEFAULTS.md, pinned by a test). Not covered: a form nested more than four containers deep, or held by a container that is not adict/list/tuple(adeque, aset, aQuerySet), stays flattened as before; the Tokio-actor transport's MOUNT frame hands the enginestr(form)(identical before this change; the render after an event is correct), pinned as a strictxfail. -
A native component tag gets a tuple, a UUID and a Path as the value it is, not the engine's stand-in (#3768). The Rust engine hands a native handler its operands as text or JSON, so
{% approval_gate reject_label=v %}overv = (1, 2)printed[1, 2]where Django's tag prints(1, 2), and{% live_counter value=v %}over aUUIDshowed0where Django countsint(uuid).scripts/lib/template_library_parity/typed_values.pymeasured 495 such cells (cells where the Python tag renders a value differently from its stand-in, and the engines agree on the stand-in) over 208 tags, 15 value types and 21,390 cells; the count is now 45, 450 fewer. Over ALL 21,390 cells (Rust output equal to Django's, exact status and text, base against this change) 517 more now agree, 0 newly disagree, 15,341 agreed before and still do, and 5,532 still differ for reasons unrelated to a stand-in. Every inline and block component handler now declaresTYPED_ARGS(one wrapper at registration, not a class attribute on ~180 of them) and_parse_args, the one place they read their operands, takes back aUUID, aPathand atuple. A tuple comes back asTupleValue, alistsubclass that prints like the tuple: ~55 handlers read a list slot withisinstance(x, list), and a realtupleturnedrows=my_tuple_of_dictsinto an empty table (the first version of this fix did that todependent_select; a measurement over 13 probe shapes found it). Both the conversion and the printing walk an explicit stack: a tuple nested 500 deep rendered before this change, and a recursive walk raisedRecursionErrorfor it. (Past ~1000 levels the engine's own context serializer raises on the unchanged code, before any handler runs.) The block tag needed the engine's twin of the inline channel (add_typed_operands, the one function both call sites use). A wrapped handler also declaresTYPED_ARGS_STANDINS_ONLY, so the engine hands over only a tuple or an object operand and a large list or dict is not materialised a second time as Python objects. Behaviour change: aUUIDorPathgiven to a parameter that takes a list or text used to render (the handler iterated the value's text as characters) and now raisesTypeError, as Django's tag does, foranimated_number separator=,carousel images=,data_grid columns=/rows=,multi_select options=/selected=,notification_center notifications=,table_of_contents items=,tag_input suggestions=/tags=andtree_view nodes=(11 parameters of 8 tags, each for both types;test_both_engines_raise_the_same_error). Cost, release build, base against this change, one process per case, a loaded machine: a 100,000-rowdata_table2,301 / 2,273 ms to 2,311 / 2,319 ms (+0.4% / +2.0%), peak RSS 746 / 747 MB to 772 / 772 MB (+3.5%); a 1,000-pointbar_chart+0.8% / +1.5%, RSS unchanged; a scalar tag about +3 to +5 µs per call (badge33 / 34 µs to 36 µs,modal38 µs to 41 / 43 µs). A 2,000,000-element tuple operand, the case the recovery exists for, is materialised as Python objects again: 5,027 / 5,022 ms to 5,409 / 5,416 ms (+7.6% / +7.8%), peak RSS 1,122 / 1,235 MB to 2,220 / 1,670 MB (the two rounds disagree; linear in the tuple). The first version handed every operand over and cost +25% RSS on the 100,000-row table; the narrow ask removed it. Recorded, not fixed, and pinned exactly so the number cannot grow (RESIDUAL_STANDIN_LOSS): a tuple in a slot that takes a list (the native handler stays more lenient than Django's strict tag),set/frozenset/bytes(an iterable crosses to a handler as the engine's wrapper, so its type cannot be recovered), a date in thetheme_*CSS-class slots, and aUUIDin an argumentnotification_centerignores.python/tests/test_standin_types_3768.py, gate-off verified. -
{% for g in u.groups.all %}{{ g.name }}{% endfor %}over a QuerySet renders the related rows on a LiveView and underDjustTemplateBackend, as Django and_rust.render_templatedo (#3777). The Rust queryset serializer (serialize_object_with_pathsincrates/djust_live/src/lib.rs) walked the extracted pathsgroups.all/groups.all.namewithgetattr:groupsis the related manager andallis its bound method, which has noname, so it shipped{"groups": {"all": {}}}and the loop ran over an empty dict. The Python codegen twin (python/djust/optimization/codegen.py) has always iterated.all(); the Rust one never did, and the key-count fallback to that codegen cannot fire because thegroupskey is present. Only a QuerySet was affected, so alist[Model]of the same rows rendered the names. Sibling spellings of the same missing step were wrong too and are fixed with it:{{ u.groups.all|length }}counted the characters of the QuerySet'srepr(41 for a two-row relation),{% if u.groups.all %}was true for an empty relation, andu.groups.all.0.namewas empty. This holds for a custom QuerySet class (SomeQS.as_manager(), which a related manager'sall()hands back) as well as a plain one: the check is anisinstance, not a class-name test. Every row reached through.allgoes through the serialization floor exactly as a row reached any other way does (password,is_superuser,is_staff, a model'sdjust_exclude_fieldsanddjust_serializable_fields), at every depth of a chain. A calledallstill goes through the onetemplate_call_guard(#3772), so a related manager whoseallis markedalters_dataordo_not_call_in_templatesis never run and its name is omitted. A raisingall()is no longer swallowed: only the two exceptions the codegen catches (TypeError,AttributeError) become an empty list; a database error propagates to the caller's existing[JIT ERROR]handler, which logs it and falls back, instead of rendering a user with no groups. Rows that are plaindict/list/tupledata (a model method namedall) keep their data. Pinned throughDjustTemplateBackend, a LiveView's HTTP GET, its WebSocket mount and the serializer itself, with Django's engine as the control and real rows (python/tests/test_queryset_all_traversal_3777.py,python/tests/test_queryset_all_floor_and_errors_3777.py). Same-symptom paths this does not reach (a QuerySet nested in a dict value,.all.count/.all.first, no row cap) are tracked at #3788. -
A QuerySet inside a dict or a list of dicts renders its related rows on a LiveView,
{{ u.groups.all.count }}/.all.exists/.all.first.namerender instead of empty, and a calledallhas a row cap (#3788). Three same-symptom paths #3785 (the #3777 fix) did not reach, each re-measured against the real render path before the fix. (1){"d": {"users": qs}}with{% for g in x.groups.all %}rendered empty while{{ x.username }}worked:get_context_dataonly read the template when a TOP-LEVEL value was a Model / QuerySet, and the JIT looked its paths up by the dotted named.users, which the extraction map (keyed by root name) does not have, so the value fell to the flat field-dict serializer. The template source is now read on the first nested DB value (a context with none pays onetype()lookup per value, not a template read), a dotted name resolves to its root's paths without the leading segments (_paths_for_variable, one resolver for the QuerySet, model and model-list producers), a list of dicts is walked like a dict, and the generated serializer's function name is sanitised (serialize_d.users_…did not compile). The same lookup fixed a Model and a model list inside a dict beside a top-level QuerySet, which rendered{{ d.user.username }}empty from the identity-only map. (2)allread only through QuerySet methods is shipped as{"count": n, "exists": b, "first": {...}, "last": {...}}, each method run on the QuerySetall()returned (COUNT,EXISTS,LIMIT 1) under the one call guard, afirst/lastrow through the serialization floor (and, when the row is also read whole,{{ u.groups.all.first }}beside.first.name, carrying the__str__a serialized model carries, so it prints its text rather than a dict). The tree cannot tellu.groups.all.countfrom{% for i in o.items.all %}{{ i.count }}, so the method reading is used only when the template does not read the relation as rows (a whole-path mark,with_whole_marks/ALL_WHOLE_KEY), every child is a method name, and the row model has no attribute of that name. Rust (serialize_all_methods) and the Python codegen twin (all_methods_value) are compared intest_rust_and_codegen_ship_the_same_methods; the codegenallstep now also honoursalters_data/do_not_call_in_templates, as the Rust one does. (3)LIVEVIEW_CONFIG["queryset_all_row_cap"](default 100,000;0, or any value of 2**62 or more, removes it;Noneis the same as unset, i.e. the default; a non-integer is logged and replaced by the default) bounds the rows read through a calledall(qs[:cap + 1]: a SQLLIMITon an unevaluated relation) in both serializers, logs a warning when a relation is cut, and never applies to.count/.exists/.first/.last. A container is searched four containers deep; below that the QuerySet is left as the view built it, a flat field dict, so its relations render empty with no error. The cap bounds the work the serializer does, not memory: the optimizer still prefetches the whole relation first. Not fixed, tracked at #3801: a template that reads the same.allas rows AND through a method still renders the method empty on a LiveView (a loop next to.all.count,{{ u.groups.all|length }}/{{ u.groups.all.count }},{% if u.groups.all %}{{ u.groups.all.count }},{% with gs=u.groups.all %}{{ gs.count }},|sliceplus.count); a model with acount/exists/first/lastfield keepsalla list of rows, so.all.countstays empty there, and on alist[Model]the Python serializer drops a row's integercount/existsfield (pre-existing);{{ g }}beside{{ g.name }}inside a loop over.allprints a dict (pre-existing, the loop's iterable cannot be told from a whole read); the extractor erases the index of.all.0.name; the relation prefetch loads all rows regardless of what the serializer ships; and a leaf-onlyallrunsstr(row)for every row. Pinned throughDjustTemplateBackend, a LiveView mount, an event re-render, an HTTP GET and a WebSocket mount, with Django's engine as the control (python/tests/test_queryset_all_followups_3788.py). -
A relation read as rows AND through a QuerySet method renders the method on a LiveView, a row's integer
countfield survives the Python codegen, and a Model past the nesting bound is reported (#3801). (1){% for g in u.groups.all %}beside{{ u.groups.all.count }}rendered[:staff,ops,]where Django renders[2:staff,ops,]: both extract to onegroups.allnode and one serialized value cannot be both the list the loop iterates and the object.countreads. The same held for|lengthplus.count,{% if %}plus.count,{% with gs=u.groups.all %}{{ gs.count }},|sliceplus.count,.first/.last/.existsbeside a loop, and a row model with acount/firstfield. The evaluated rows of exactly such a name (a top-level QuerySet orlist[Model];codegen.all_methods_need_rows) now ride the render sidecar and the engine's guarded getattr walk answers the method from them, the walkDjustTemplateBackendalready used: a method markedalters_dataordo_not_call_in_templatesis still never called, and the serialization floor still governs every row. A plain loop and a method-only read keep no rows. (4) The Python codegen's leaf branch called a leaf namedcount/exists/allas a method even when it was an integer field, so alist[Model]row lost itscount; only a callable is called now. (7) A Model or QuerySet directly inside the first container past_NESTED_DB_DEPTHis left as a flat field dict (relations render empty); that is now logged once per process, when the container is reached from a list-rooted value (a dict-rooted shape is not reported). Not done, filed as #3862: the erased index of.all.0.nameand the relation prefetch (2),str(row)per row in the leaf shape (3),{{ g }}beside{{ g.name }}printing a dict (6), and the mixed read on a QuerySet nested in a dict. -
_rust.serialize_contextno longer overflows the stack on a symmetrical self-referential relation, a container that holds itself, or a very long chain of related rows (#3814). WithPerson.friends = ManyToManyField("self")and A <-> B,_rust.serialize_context({"p": a})ended the interpreter withFatal Python error: Segmentation fault: the all-fields model arm builds a model's dict withmodel_to_dictand recurses into every value, and a relation hands back a FRESH instance of the row it came from, so no identity check ended it.guard_paniccannot catch that (it is the process's stack, not a Rust panic), and a list or dict that contains itself did the same. Every recursive step now goes through one function,serialize_nested, which fails CLOSED tostr(obj): into a container or a model row (class + primary key) the walk is already inside of, or past a depth ceiling (128 levels, for a chain of DISTINCT rows). The row check runs beforemodel_to_dict, so the query that would fetch the next ring of the cycle is not made. A row reached from two different parents is not a cycle: the path is popped on the way out. Before, in a bounded child process, the model, list and dict cases exit with SIGSEGV and the non-strrender()raises; after, all exit normally with bounded output.A
render()that returns a non-str(anint,None) no longer raisesTypeErrorout ofserialize_context; it falls back tostr(obj)exactly as arender()that raises does.The production serializers were checked for the same shape and do not have it, so none was changed.
serialize_object_with_paths/serialize_querysetwalk the template's field-path tree, which is finite whatever the data is (andqueryset_template_valuecarries its own 64-level cap), and the Python encoder is capped atserialization_max_depth. Run against the same relation throughserialize_queryset, theDjustTemplateBackend({{ p.friends.all.0.friends.all.0.friends.all.0.name }}, a{% for %}overp.friends.all, a bare{{ p }}), a LiveView attribute and the encoder, each exits normally on the unchanged build as well.serialize_contexthas no production caller (#3778).Behaviour changes to know about.
- Nesting past 128 levels now crosses as
str(). A level is a container, a model, or a model's dict, so a chain of related rows is cut after about 42 rows (three levels per row) and a list nested 129 deep is cut at the 129th. Before, such a structure was returned whole up to the Python recursion limit (or crashed the process if it was cyclic or long enough). The ceiling is nesting, not width: a flat list of any length is untouched. - The fallback is
str(obj), so an object whose__str__raises now makesserialize_contextraise at a cycle or at the ceiling (before: a segfault), and a__str__that embeds a field value puts that value in the output. A model's__str__is public text by contract (#3806), as it is everywhere else it is rendered. - Stack: the worst chain (a model per three levels, up to the ceiling) needs about 112 KB of native stack in a release build (measured in the review of this change, where 96 KB crashed with SIGBUS). That is a large margin on the default thread stacks (8-16 MiB), but a thread started with a very small stack would have less.
Regression cases in
python/tests/test_serialize_context_cycles_3814.py, each run in a child process watched on the clock and on memory (a segfault is exit-11), with a control that proves the harness sees a crash. The row check is pinned to run beforemodel_to_dictby a query count (6 for A <-> B, 9 if the third row were read), a flat list of 1,000 and two classes sharing a primary key pin that the ceiling and the row key are not over-eager. Each mechanism (container check, model-row check, ordering beforemodel_to_dict, depth ceiling and its decrement, the (class, pk) key, the shared chokepoint, the pop, the non-strfallback) is gate-off tested separately. Tuples are not tracked (a tuple cannot hold itself without a list or dict between, and those are). - Nesting past 128 levels now crosses as
-
A dict
lazy=on{% live_render %}works under the native (Rust) engine (#3838).{% live_render "app.views.Child" lazy=cfg %}withcfg = {"placeholder": ..., "trigger": "visible"}rendered under the Django engine but raisedTemplateSyntaxError: lazy= accepts True, 'visible', or a dict - got '{"placeholder":...}'under the native one, because the engine hands a handler a structured operand as a JSON string.LiveRenderTagHandlernow declaresTYPED_ARGS(the channeljson_vieweruses, #3756) and readslazyas the value the engine resolved, not the string decoded back: a string cannot carrySafeString-ness, and the #3819 placeholder contract depends on it. Amark_safeplaceholder is markup and a plainstris text on both engines, byte for byte. The same read means a string variable holding the text'visible'(quotes inside) is refused as Django refuses it; the native handler used to strip the quotes and accept it. Onlylazyis read this way; the other keyword operands are the child'smount()kwargs and cross as they always did.TYPED_ARGS_KEYnow lives indjust.template_tags(re-exported bydjust.components.rust_handlers).tests/unit/test_native_live_render_lazy_dict_3838.py, gate-off verified (6 mutations). -
A namedtuple that cannot be rebuilt for rendering is logged through
log_failure(#3849). The debug log passed the user's exception to the logger directly, whichtest_log_exposure_pinforbids (ADR-038); the exception text now ridesexc_infoand only where diagnostics are allowed. -
The engine remembers a namedtuple shape
collections.namedtuple()refused, and "cached per shape" is now tested (#3849). Follow-up to #3856 from its independent review. The engine rebuilds a namedtuple's class for a native tag withcollections.namedtuple(name, fields)(named_tuple_class,crates/djust_core/src/lib.rs) and cached only a shape it BUILT: a shape the factory refused (arename=Trueduplicate, a leading underscore, a keyword, a non-identifier name) re-ran the factory, anexecthat raises, on every conversion (115 ms against 92 ms per 20,000 refused rows). A refusal (ValueError, or theSyntaxErrornamedtuple()raises for fields equal only after NFKC normalisation, which was not cached either and escaped the Python shape gate, failing the render) is a fact about the shape and is cached as such; no shape raisesTypeError, so it is no longer caught; any other failure (MemoryError, an exception from a patched factory) is not, and is tried again; the value still falls back to the plain tuple either way. Thelru_cacheon the Python shape gate (_namedtuple_shape_is_buildable_cached) had no test; both caches are now pinned by COUNTED work, the number of times the factory runs for a shape over 25 conversions (python/tests/test_namedtuple_shape_cache_3849.py), not a duration. Recorded, not changed, with the measurement that decided each: a namedtuple (or a plain tuple,UUID,Path) INSIDE a list or dict operand is still its JSON text ([[1, 2]]where Django prints[P(x=1, y=2)]; handing over a container that holds one keeps the whole operand in memory a second time, +24% peak RSS on a 100,000-row list, time unchanged), pinned by strictxfails inpython/tests/test_namedtuple_in_container_operand_3849.py. The custom-__repr__and tuple-depth items of the review stay recorded as in #3856. -
A native component tag gets a namedtuple as the namedtuple it is, and recovering a large tuple does less counted work (#3849). Follow-ups to #3843 (#3768). (1)
{% approval_gate reject_label=v %}overP(1, 2)printed(1, 2)on the Django-backend engine ([1, 2]before #3843; Django printsP(x=1, y=2)), and seven cells flipped from their default to(1, 2):dashboard_gridgap/row_height,map_pickerheight,resizable_panelinitial_size/min_size/max_size,sortable_gridgap. Two causes, one per layer:serialize_value(for_render=True)rebuilt every tuple astuple(items)(a namedtuple is now rebuilt positionally and keeps its type, so{{ v }}printsP(x=1, y=2)as Django does), and the engine's narrow ask (TYPED_ARGS_STANDINS_ONLY) handed over aValue::Tuplebut not aValue::NamedTuple(it does now)._parse_argsreturns aNamedTupleValue, aTupleValuethat is alist(theisinstance(x, list)slots keep working) and printsP(x=1, y=2); a namedtuple nested in a tuple is converted too. A namedtuple the engine cannot rebuild never fails a render:collections.namedtuplerefuses some shapes a real class can have (arename=Trueduplicate is('a', '_1', 'b')and a leading underscore is refused; a keyword or non-identifier field; a class whose name is not an identifier), and the engine's conversion of the context back to Python for a native tag raised for ANY tag in a template that merely held such a value ({% static %},{% now %},{% badge %}), operand or not.serialize_valuekeeps the type only whennamedtuple(name, fields)accepts the shape (the real constructor is the test, cached per shape), and the engine falls back to the plain tuple when it cannot build the class (named_tuple_or_tuple,crates/djust_core/src/lib.rs), for a value that reaches it without passingserialize_value(RustLiveView.update_state). The rebuild runs the class's own__new__on already-serialized values (a lazy string is astrby then), so ANY exception it raises degrades to the plain tuple, logged at debug. Pinned for every shape on both native entry points (the Django-backend engine andRustLiveView) against Django's own tag: plain, namedtuple (one field, none), tuple in namedtuple and the reverse, nested, empty, one-element and a tuple subclass (python/tests/test_typed_tuple_shapes_3849.py), and for each refused shape (python/tests/test_namedtuple_refused_shapes_3849.py). Recorded, not changed: a tuple subclass that overrides__repr__and a namedtuple with a custom__repr__print the default (the engine keeps no class); a tuple held as LiveView state is a list before the engine (normalize_django_value);{{ rows|dictsort:'x' }}/|dictsortreversedover a list of namedtuples still render EMPTY natively where Django sorts them (unchanged). (2) The review of #3843 measured a 2,000,000-element tuple at +34% time and +39% RSS, more than #3843 stated. The avoidable work is gone and pinned by COUNTED work, not a duration:_parse_argsno longer decodes the JSON text of an operand the typed value replaces (the test assertsjson.loadsnever sees it), and_from_typedcopies a tuple with no tuple member in one C-level pass instead of a Python loop per element (the test asserts the executed source lines are the same for 10 and 20,000 elements). The wall-clock effect is small and noisy: an independent run throughapproval_gatemeasured about 2 to 4% faster with the same peak RSS, a bare-handler run about 10% faster and 3% less RSS; do not read more into either. What remains is the engine's own: the operand is serialised once as the string argument, resolved a second time as a typed value and built once as Python objects. A lazy sequence view was considered and not done: ~55 handlers read a list slot withisinstance(x, list). Not addressed: the tuple-depth label difference at 1,100 and 3,000 levels (the render raisesRecursionErrorinserialize_valuefirst, unchanged) and the run-to-run variation of the reproducible cell counts (the reviewer's own measurement note). -
The safe-markup guard no longer trusts a class attribute the class body may not define, and checks where
safe_urloutput lands in the string-building forms it knows (#3850). Soundness follow-ups from two reviews of #3848. In the 322-function review corpus, the fuzz programs run (4,800 here, 12,800 in the review) and the real tree the guard clears nothing new (the audit baseline is unchanged except the correctedJSChain.__html__reason); that is measured, not proved. A class attribute defined only inside anif/try/loop, or deleted bydel C.X/del X, is trusted only when the class has no__getattr__(on it, on a subclass in the file, or assigned from outside), no base other thanobjectand no class keyword; the same holds for a method. The position check onsafe_url/url_attr/safe_css_*(text or a quoted value, never an unquoted attribute or a tag name, including a trailing<) andescape_json_for_script(script text only) now covers+chains,%andstr.format(a prefix held in a name, returned by a helper of the file or passed as a literal argument counts), and a string built from such an output ('' + safe_url(x),'%s' % ..., a helper that returns one, ajoinor container of it) is judged again where it is placed unless it is a balanced attribute fragment of its own (placed unchecked, see #3864); a bare output where nobody checked its position is not clean. Not covered, listed in the guard's docstring and #3864: an unreadable prefix is assumed position-neutral, a function that takes the output as a parameter and builds the tag, a lambda, a metaclass descriptor, hostile monkeypatching. A+chain longer than the depth bound is flagged instead of crashing the scan. Directed rows pin every rule. -
A formset whose form adds fields in
__init__can mark rows for deletion (#3861).remove_rowexempted frommax_form_fieldsonly the fields a form class declares (form.base_fields) and an inline formset's foreign key, so a form that addsd1,d2,d3in__init__counted all of their seeded row keys: with 600 rows (1,800 such keys) none of the 600 deletes was stored. The exempt names now come fromformset_cls().empty_form.fields(the unbound form, so they are the class's and never the client's), read once per formset class per process (a form whose fields depend on state that changes later keeps the first answer) and cached weakly, over an empty queryset for a model formset so no rows are read; a form that cannot be built without arguments keeps the declared fields exempt as before and logs the fallback once. Tests added for the inline foreign key (a realContentType/Permissioninline formset) and for theTOTAL_FORMSclamp'smaxagainstmin. #3867 adds tests only (no behaviour change): the kept-rows sidecar entry never replaces an explicit entry, never ships for a name outside the context, and nothing is kept for a QuerySet reached through a container. -
The Python
data_tabletemplate rendered an empty cell for every cell of a dict row, an emptydata-valueon every clickable row, and readsortable: Falseas True (#3870).table.htmlread a cell as{{ row|dictsort:col.key|first }}, butdictsortover a dict iterates its KEYS and resolves the column key against each, so the lookup was""; the same expression gavedata-value(row_click_value_key),data-href(row_url) and the href of a link column (col.link).{% if col.sortable|default:True %}turnedFalseinto True, so an unsortable column still haddj-clickon its header. Cells now come from atable_cellfilter (a dict row's value;""for a missing key or a row that is not a dict; written withstr()like the native table, so a number is not localised and1000is not1,000; amark_safestring is kept, as every Python tag does), andsortableisdefault_if_none:True. The two URL sinks the fix makes live go throughtable_href(url_attr):javascript:,data:andvbscript:become#, the value is escaped, and a quote cannot end the attribute. The native table readssortable: Noneas "not given" too, as the template does.table.htmlnow begins with{% load djust_components %}, so a test that renders it with a bareEngine()registers the library (the three that did are updated). The native table'sdata-valueand cells are pinned equal to the Python template's for dict rows with a missing key, aNone, falsy values, a key nameditems, numbers and a hostile payload sweep. Rows that are not dicts still render nothing natively and an empty cell under Python, as before. A row'sdata-valueis force-escaped, so amark_saferow value cannot leave the attribute. -
A native BLOCK tag's argument naming an unresolved variable became that variable's NAME (#3871).
{% nav_menu toggle_event=nope %}withnopeundefined rendereddj-click="nope"under the socket render (an event name the view may well have), and{% card title=nope %}printednope; the Django tag renders both empty, and an inline tag's argument ({% dj_button event=nope %}) already did on both engines. The engine resolves a block tag's arguments through a channel that keeps the raw token when a name does not resolve, so the handler was handed text it could not tell from an operand that resolved TOnope(a lookup against the context, thecontext.get(val, val)in_parse_args, answers a different question). Block handlers now declareTYPED_ARGSwithoutTYPED_ARGS_STANDINS_ONLY(_TypedBlockOperands): the engine says which operands resolved, and the wrapper empties exactly the ones that did not, so a block handler sees what an inline handler sees. A quoted literal, a number,True/False/None, a dotted lookup, a falsy variable (0,"",None,False) and a value that merely reads like an unresolved name (shadow = "nope") are untouched. Still different, and pinned intest_a_value_that_is_also_a_variable_name_is_resolved_a_second_time: that samecontext.get(val, val)fallback looks a RESOLVED value up a second time, soev = "name"prints whatever the variablenameholds natively; it stays because about 400 tests call a handler'srenderwith a raw token and rely on it (filed separately).python/djust/tests/test_component_arg_handling_3870_3871_3872.pyrenders 24 argument shapes throughnav_menu,nav_item,filter_bar,card,dj_buttonandbadgeon both engines. The{% call %}/{% component %}handlers keep their own argument reader, whose operands arrive as raw tokens by declaration (RESOLVE_ARG_POSITIONS), so a lookup there is the resolution, not a second one. Behaviour change to know: an unresolved operand on a block tag (title=view.page_title, a namedtuple attributept.x, both of which resolve on neither engine) now renders empty instead of its own text. -
Native component handlers stripped the whitespace of a variable-valued argument; the Django tags keep it (#3872).
{% dj_button event=ev %}withev = "x "(or"x\n","x "," x") rendereddj-click="x"natively anddj-click="x "under Django, and{% badge label=ev %}lost the same padding from visible text._parse_argsranval.strip()on the argument text, but the engine splits a tag on whitespace, so a source token never carries any: only a RESOLVED value was ever stripped. The text is now read as it arrives; a padded text is never read as a number (int(" 5 ")is 5) or asTrue/None, and a lone quote character is no longer read as the empty literal. A lone space now wires on both engines. Still different, and pinned intest_the_text_channel_cannot_tell_the_string_zero_from_zero: the string"0"reaches an inline handler as the number 0 (the engine hands handlers text, and a falsy count must wire nothing). Behaviour change to know: a resolved value that carries leading or trailing whitespace is no longer stripped natively, as the Django tag never stripped it, sodj-click="go "no longer dispatches, andpagination page=" 2 ", which rendered natively before, now raisesTypeErroras the Django tag does. -
{% include obj %}in a child view's template called arender()markedalters_dataordo_not_call_in_templates(#3880). #3879 refused it on five entries (DjustTemplateBackend,_rust.render_template,RustLiveView, a LiveView's WebSocket and HTTP GET renders), but embedded and sticky child views (websocket.py::render_embedded_child_html,templatetags/live_tags.py::_render_sticky_child_html) render through Django's stock engine, which called it. The engine is unchanged (no change to filters, tags or escaping): the render is now made insideinclude_guard_scope(python/djust/_include_guard.py) and a patchedIncludeNode.render, inert outside that scope, applies the samerefused_markercheck as the five entries, so a flaggedrenderis not called, the include renders the empty string and one WARNING is logged per type. The operand is resolved once and the checked bound method is the one that runs. The same scope now wraps the other places a view's or component's context reaches Django's engine: the two remaining{% live_render %}branches (legacy non-sticky and lazy), streamed fragments, pending layouts, the native renderer,dj_suspensefallbacks, the component gallery, a component's Django-engine fallback and{% extends %}path (both component kinds), the template of an inclusion tag bridged into a LiveView render, andLiveViewTestClient.render(engine="django"). A render no djust view or component is making (your own page, the admin) is still stock Django. Regression cases inpython/tests/test_render_call_guard_3815.py(the two child-view tests that pinned the old behaviour are flipped). -
The native
data_tabledrew norow_urlnavigation and no link columns, so they vanished when the socket opened (#3887). The Pythontable.html(the first render, under Django's engine) drawsrow_url="key"asdata-href,role="button",tabindex="0",style="cursor:pointer"and thedata-table-row-clickableclass on each<tr>, loadsdata-table-row-click.js(which does the navigating), and draws a column with"link": "key"as<a href>(withlink_class); the native handler implemented none of it, and its row-click rows lacked the role, tabindex and the script too. The native handler now writes the same markup, with two exceptions: arow_urlvariable holding the text"0","None"or"False"is a clickable row in the Python tag but not natively, because the native handler re-types the text it is handed (#3882, #3883), and a row whose URL value isNonestill draws<a href="None">on both engines (#3895). Every URL goes through the sameurl_attrallowlist as the Python tag (javascript:,data:and the other non-navigation schemes, case-folded, whitespace-, control-character-, entity- and percent-obfuscated, become#; the value is escaped),row_click_eventstill wins overrow_url, and a falsyrow_url/link(None,"",0,False) draws nothing, decided on the value.test_data_table_row_url_link_3887.pyrenders every shape through both engines (the sweep's 60 URL payloads and 17 more, three placements, 15 row-value shapes, 27 link-column forms, 27 parameter shapes, callables behind the call guard, marked-safe and__html__values called through the handler directly) and compares what a browser acts on; the parity baseline movesdata_tablefrom 118 to 120 readable parameters and 20 to 16tags/other-attrsdifferences. Amark_safeURL is still kept raw by the Python tag (#3894); the other residuals are listed in #3895. -
A relation read inside a filtered loop over a list of models renders empty on the Django-backend engine (#3888).
{% for u in users|slice:':2' %}{% for g in u.groups.all %}{{ g.name }}{% endfor %}{% endfor %}over alist/tupleof models (or aQuerySetat a nested name such aspage.users) rendered nothing where Django's own engine prints the groups; so did{{ u.groups.all.count }},{{ u.groups.count }}, a*_idattname such as{{ p.content_type_id }}, and the same reads behind|first,|dictsortand{% with s=users|slice %}. A model that reaches the engine inside a container used to be a dict of its fields, and anything the dict lacked was read through a loop alias that a filtered operand never registers. Such a model is now a floored live handle: the row carries its own handle through any filter, and the handle is the floor proxy the template sidecar already used, so every attribute read, relation traversals included, is checked against the serialization floor (the fields, methods, per-model rules and field types it withheld before stay withheld, the live handle refuses underscore-prefixed names for any reader, and anything a read returns is plain data or another floored handle (a model, manager or queryset becomes a proxy; a callable becomes a wrapper whose result is floored again; a class or module is refused, so{{ u.DoesNotExist }}renders empty; the wrappers report a class with no Python-level methods and close routes to Python-defined objects and namespaces: a UUID is its text, plain data is matched by exact type, a dict's keys are floored like its values, and a datetime or time with a Python-definedtzinfocarries a floortzinfothat answers the same offset, DST and name questions), andalters_datamethods are still never called). The rows a relation read returns (u.groups.all,u.groups.first) are handles too, so a second relation hop works.{% if u == rows.0 %}/u in rowscompare models by class and primary key,|dictsort:'field'(dotted keys included) reads the key off the row once per row,{{ u }}isstr(u), and a model written as JSON for a tag argument or to a state snapshot is the same floored dict it always was. Scope: only a model that reaches the engine inside a container; a top-level single model is still serialised first and keeps the dict form on a LiveView (#3936) —DjustTemplateBackendcarries it as a handle too since #3902 — so on a LiveView{{ u|pprint }}of a top-level model dumps its fields while{{ rows|first|pprint }}prints its name. Behaviour changes for a template that depended on the old dict: a model in a container is no longer a mapping, so{% for k, v in u.items %}renders empty, as on Django; a bareFileFieldprints its stored name, as on Django ({{ p.image.url }}is the URL on both); a custom Python filter or simple tag that receives such a model gets the floor proxy (attribute reads work;row["field"]does not) where it got the dict, while a top-level model on a LiveView or a top-level queryset loop still gets the dict; and{{ rows|json_script:"x" }},{{ rows|pprint }}and a data-dump tag such as{% json_viewer %}spell each model in a container as itsstr()(["u0", "u1"]) where they dumped its fields (Django raises for thejson_scriptcase), so code that reads such data in JavaScript must build the data explicitly. A bridged or native tag that takes a list of models as an argument ({% data_table rows=rows %}) still receives dict rows. LiveView state, the client payload and the msgpack snapshot are untouched. Not in this change:dictsortover a field the template reads nowhere else, on a QuerySet or in a LiveView, still renders empty (#3892). -
params_form_fallback = "fields"handshandle_params()the value the valid path would for a kept field the URL left out or empty, when that field has aninitial(#3891). On a valid URL the form'sclean()runs first and such a field then takes itsinitial, soclean()'s write to it is overwritten. The"fields"fallback filled theinitialbefore its extraclean()check and then adoptedclean()'s output, soclean()'s write survived: aclean()that copies a request value into another field handed a value that had failed validation tohandle_params()through a field the URL never named. Theinitialfill is now applied again to the check's output, with the helper the valid path uses. The check itself still runs on the combination that would be handed over, so the result stays a combinationclean()accepts, or the plain initials. Behaviour changes: aclean()that sets a default into such a field now loses to the field'sinitial(as on a valid URL), and the fallback hands over a copy of a mutableinitialit fills in (the valid path and"initial"mode hand over the object itself, so ahandle_params()that mutates it there changes the form class's default for later requests: #3926). Not covered, because the valid path shares it: a field with noinitial, aclean()that also writes the field intoself.data, and a multi-select field the URL left out (#3922). A field the URL supplied still keepsclean()'s output on both paths, a failed field is still itsinitial, and"initial"mode is unchanged. Guide: Navigation, "Keeping what validated". -
{% for u in users|dictsort:'username' %}{{ u.pk }}{% endfor %}over a QuerySet, or a QuerySet /list[Model]on a LiveView, no longer renders an empty loop when the template readsusernamenowhere else (#3892). A path-JIT row holds only the fields the template reads, and the template-variable extractor (extract_template_variables,crates/djust_templates/src/parser.rs) attributedu.<field>reads in a loop body and nothing else, so a field named only in a filter ARGUMENT was never in the row; Django'sdictsortanswers''for a missing key rather than raising, so the loop was silently empty. The quoted argument ofdictsort/dictsortreversed(a table of the built-in filters that read a named attribute of each item, measured against Django: exactly these two) and thebyattribute of{% regroup %}(which grouped byNoneon a LiveView) are now filed on the list beside the fields the body reads, through a loop, a{% with %}alias, a{{ }}or an{% if %}operand, and a nested path (page.users). One extractor servesDjustTemplateBackendand the LiveView, so both are fixed together. The key is filed only when the items are also read by field: with nothing else singled out the row is serialized whole, which already carries the key and the__str__a bare{{ u }}prints, so{% for u in users|dictsort:'name' %}{{ u }}keeps working. A key that is not a plain path (empty segment, a segment starting with_, as Django refuses) is ignored, and the serialization floor applies to a sort key as it does to{{ u.password }}. Not fixed here and filed: a context-variable key (dictsort:key, #3910), a dotted key the Rustdictsortcannot sort by (#3912), and a loop variable read whole next to a field (#3911). New cases inpython/tests/test_item_key_filters_3892.py; the strict xfails ofTestDictsortOverJitRowsinpython/tests/test_sliced_list_of_models_3888.pyare removed. -
A
Component's hybridtemplatenow applies the attribute rule of the Rust instance and_render_custom()to its attribute values (#3900).Component.renderdraws a class that has atemplatethrough it when the crate constructor refuses a value (a lazy string,gettext_lazy/format_lazy, is one; so is any object that is not plain text) and always when the class has no crate counterpart (ButtonGroup,Checkbox,Input,Offcanvas). That path wrote{{ src }}into an attribute with only autoescape and no URL check, soAvatar(src=<lazy "javascript:alert(1)">)printed a live script URL where the plain string printed<img src="#">, and a lazysize/statuscould leave its attribute. Now every{{ ... }}in the value of an attribute of a start tag is escaped whatever the value is marked as, however it yields its text (a marked-safe string, a lazy one, an object whose text form is marked safe, a callable the engine calls), also under{% autoescape off %}; and in a URL attribute (href,src,action..., and thedj-navigate/dj-patch/dj-patch-reloaddirectives) the value is judged with the scheme allowlist the other paths use (url_allowed,data_url_attr,srcset_attr), so any scheme outside the allowlist (javascript:,data:for a link,sms:,magnet:...) becomes#. The template is read once per template string (a bounded LRU cache) with the HTML attribute grammar (whitespace around=, quoted and unquoted values), so it covers the 19 shipped hybrid classes and a template you write yourself. Behaviour changes for your own hybrid components: a marked-safe value in an attribute is escaped again (mark_safe("a & b")printsa &amp; b; pass plain text); in an attribute a number prints in its canonical form (33.5, not a localized33,5) and a date in its ISO form (2026-01-02, not a localizedJan. 2, 2026), for a plain name, a dotted path and a filtered expression alike; a callable that needs arguments prints nothing, as in Django; and the hybrid path is now stricter than_render_custom()for a scalar argument in a non-URL attribute. A URL is judged as the WHOLE value in every way it can come out: a literal inside{% if %}/{% for %}may be skipped, so it never fixes the value (each branch is a path of its own, and a good value that follows a block-added literal is judged as if the block were skipped), and a literal is read the way a browser reads it (character references decoded, tab and newline dropped). A slot that can still choose the scheme (first in the value, or only scheme characters before it) is judged together with the literal text and the other slots; a slot after a top-level literal that already fixes the value (href="/search?q={{ q }}",href="http://{{ host }}/",href="{% url 'x' %}?q={{ q }}") is escaped, not judged, sofoo:barandhost:portare kept. A URL value that can choose the scheme but whose slot is a dotted path, a filtered expression, a{% for %}/{% with %}variable, sits beside a tag that prints text this module does not follow ({% trans %}), has more than 64 paths, or has an attribute NAME built by tags, is escaped but not judged; asrcsetis judged only when the slot is the whole value; an unquoted value is escaped but cannot be made safe. Each of these is named by one warning per component class and set of attributes, which carries the class name and the tag / attribute names only, never template text. A value whosestr()raises raises in a URL slot as the engine does; in a plain slot the Rust engine'sforce_escapeprints the escaped default repr where Django's raises (the crate's filter, unchanged).{% verbatim %}and{% comment %}blocks,{{ }}in an attribute name and text between tags are left as written. The sweep intest_marked_safe_attribute_sweep_3898.pyno longer sets the hybrid cells aside: it drives every template class through the template and throughComponent.render, with a claim for each kind of value, and its reach test asks per claim kind. -
A filtered rebinding of a top-level single model reads its relation empty through
DjustTemplateBackend(#3902).{% with q=user|default:user %}{{ q.groups.count }}{% endwith %}over ONE model passed as a top-level context variable rendered nothing where Django's own engine prints the count; so didq.groups.exists,q.groups.first.name,{% for g in q.groups.all %}, a model method that returns a queryset, a method call on the model through the rebinding ({% with q=obj|default:obj %}{{ q.describe }}{% endwith %}), a*_idattname, and{% if user in users %}.DjustTemplate.renderreplaced the model with a dict of its fields before the engine saw it, so a filtered operand (which registers no loop alias) had no manager and no relation to read. A plain render is not state, so the model now reaches the engine whole, as a list row has since #3888: a floored live handle, checked against the serialization floor on every attribute read, with the same refusals (underscore-prefixed names, Python-defined classes and modules, a function's__globals__, a UUID is its text, a Python-definedtzinfo, a dict's model keys). A model reached through a context processor (request.userasuser, a lazy object) takes the same route.serialize_value(for_render=True)also stops treating a model as a file: it askedhasattr(value, "url") and hasattr(value, "name"), which a model with anamefield and aurlfield satisfies, so{{ link }}over a list of such rows rendered the row's URL instead of itsstr(), and a row whoseurlproperty raised anything butAttributeErrorfailed the whole render. Behaviour changes for a template that depended on the old dict (top-level model,DjustTemplateBackendonly; each was run against the old and the new code): a bareFileFieldnow prints its stored NAME, as on Django, not its URL ({{ doc.attach }}printed the URL and now prints the stored name, which has no leading slash;{{ doc.attach.url }}prints the URL on both), so<img src="{{ profile.avatar }}">written against the old dict must become<img src="{{ profile.avatar.url }}">; the model is no longer a mapping or an iterable of field names, so{% for f in user %}and{{ user|unordered_list }}now fail the render (aRuntimeError; Django raisesTypeError) where they iterated the field names,{{ user|join:"," }}isstr(user)where it wasid,pk,__str__,...,{{ user|pluralize }}is empty where it wass,{% for k, v in u.items %}renders empty and{% if 'username' in u %}is false, as on Django;{{ u|pprint }}and{{ u|json_script:"x" }}print the model'sstr()where they dumped its fields (Django'sjson_scriptraises for a model), so code that reads such data in JavaScript must build the data explicitly; and a custom Python FILTER that receives the model gets the floor proxy (attribute reads work,row["field"]does not) where it got the dict (asimple_tagalready received the proxy, so nothing moves for tags), and a filter thatcopy.copys,copy.deepcopys or pickles the model now getscopy.Error/pickle.PicklingError. A bridged or native tag argument is still the same denylist dict text, byte for byte. Not in this change: aLiveViewholds the model as state (ADR-027: client payload, msgpack, change detection), so the same template still renders empty there (#3936). -
An item value that is marked safe is escaped in the attributes the template tags write it to, for the list parameters the sweep derives (#3904, follow-up to #3898). Behaviour change: an entry of a list parameter, or a value read out of an item dict, that is marked safe (
mark_safe, a lazy one, a subclass, astrwith__html__, or an object whose text form is marked safe) used to be written as it is into an attribute (value=,data-*,id=,class=,aria-label=,fill=, ...) by 43 tag parameters, and by a form field's choice value indj_form(also through the native engine, which hands the form to the same tag) and the column keys and filter options ofmodel_table:audit_log(entries,columns),autocomplete,collab_selection,combobox(options,selected),cursors,dashboard_grid,data_grid(columns,rows),dependent_select,dj_select,export_dialog(columns,formats),fab,file_tree,gantt_chart,kanban_board(a column and the cards inside it),line_chart,masonry_grid,meter,model_selector,multi_select,notification_center,reactions,scroll_spy,sortable_list,split_button,tag_input(tags,suggestions),toast_container,toggle_group,tour,tree_view,wizard,theme_radio/theme_selectofdjust_theming, and further parameters treated the same way. It is now escaped there exactly as a plain string is (attr_textin the Python tags,|force_escapein the three templates); text slots keep their markup. A scalar tag argument is unchanged. The template protection lives in the three shipped templates: a per-theme override or a project copy made from the oldtoast.html,select.htmlorradio.htmlmust add|force_escapeto the attribute values it prints from an item.test_marked_safe_attribute_sweep_3898.pynow drives each of the 43 parameters (a cell each: the attributes the item reaches are pinned, and every kind of marked-safe value must leave them as a plain string does), derives the list parameters of the swept tag libraries at run time and tries each with the item's keys read from the tag, its other lists filled, its flags on and off, the scalars that select a branch set, nested items, pairs and an empty block body; a planted-leak test proves the derivation can report a leak, and a static hunt over the tag modules, the structural check of the two, finds an escaped item written to a quoted attribute (an f-string, a concatenation,%or.format; not an unquoted attribute,format_html, or a helper that escapes a value it is handed) without enumeration. Its list of known leaks is empty. -
scripts/gate_off_batch.pyno longer leaves its test command running after a hard kill (#3905). The test command runs in its own session, so when the tool (or the pytest worker that was running it) was SIGKILLed, nothing could kill the command's process group and a hung child spun as an orphan until someone found it. The command now runs under a small watchdog that kills its whole process group as soon as the tool that started it is gone (the deadline, RSS, SIGINT and SIGTERM kills are unchanged), and every busy loop in the tool's own test fixtures ends by itself after 45 s. Six tests assert that no descendant, a spawned grandchild included, is alive after a HUNG run, an import-time hang, a RUNAWAY-MEMORY run, SIGINT, SIGTERM and a SIGKILLed tool; the self-check list gained three mutations for the group kill. -
The inline
templateof aLiveComponentand of a class-level (BoundComponent) component gets the attribute rule of the hybridComponenttemplate (#3913). All three draw theirtemplatestring through one helper,_render_inline_template(components/base.py), which appliesdjust.components._hybrid_attrs.harden_contextbefore the engine; an AST census of the package fails when another site renders a component's inline template (under any spelling: an alias, another module, a directTemplate(...).render(...)). So every{{ ... }}in the value of an attribute is escaped whatever the value is marked as (a marked-safe string, a lazy one, an object whose text form is marked safe, a callable the engine calls; also|safeand under{% autoescape off %}), a URL attribute (href,src,action..., and thedj-navigate/dj-patch/dj-patch-reloaddirectives) is judged with the scheme allowlist on the whole value as it is assembled, and the text of<textarea>,<title>and HTML comments is escaped (#3914). Behaviour change for existing inline templates, measured over a corpus of 9,950 renders (118 differ): (1) a scheme outside the allowlist in a plain-name URL slot becomes#(href="{{ url }}"withjavascript:alert(1), andsms:,whatsapp:,zoommtg:,webcal:,geo:,slack:, silently; the no-ophref="javascript:void(0)"/javascript:;is kept, in anhrefonly, so in an<img src>ordj-navigateit becomes#); (2) a marked-safe value, or a plain value written with|safe, in an attribute, in<textarea>, in<title>or in a comment is escaped (title="{{ v|safe }}"withmark_safe('{"a": "b"}')printed the quotes raw, ending the attribute, and now prints";mark_safe("a & b")printsa &amp; b, so pass plain text); (3) a number or date in an attribute prints in its canonical form (2026-01-02,33.5, notJan. 2, 2026/33,5). Not changed:<script>,<style>,<xmp>,<noscript>... keep the author's markup (<script>var cfg = {{ cfg|safe }};</script>stays JSON; usejson_scriptor adata-*attribute to put data in a page); relative,http(s):,mailto:,tel:, fragment and query URLs, ids,component_idand every ordinary value render byte for byte as before; a text slot keeps a marked-safe value's markup. Also fixed:{% blocktranslate %}/{% blocktrans %}in an attribute (aria-label="{% blocktranslate %}Delete {{ item }}{% endblocktranslate %}"printedDeletebecause the rewrite renamed the catalog-keyed variable; the mechanism came in with #3915 for the hybridComponent). A translation block's body is now never rewritten, and what it prints is escaped once (a variable it reads from the context is escaped around it, awithargument gets|force_escape, acountargument is left alone); it works on both engines, for plural,with,trimmed,contextandasvar, in an attribute and in<textarea>/<title>/ a comment. A block in a URL attribute is escaped, not scheme-checked. What is escaped but not scheme-checked (a URL whose slot is a dotted path, a filtered expression or a{% for %}/{% with %}variable, a translation block; an unquoted value), the slots of an{% include %}d partial (neither escaped nor checked), a tag that prints text inside<textarea>/<title>, and shapes the scanner cannot follow like a browser does are named by one warning per component class (class, tag/attribute/element names, shape names and a count; never an attribute value or anything from the context; an attribute name is template text and is logged, so keep names static in a template built per instance). An{% include %}of a literal name that the loaders find and that holds nothing to harden is no longer reported. Two behaviours: a callable in a URL slot is called when the context is prepared, even if its branch does not render (it must be judged first), where a callable in<textarea>/<title>text is called only where the engine reaches it. Not changed: atemplate_namefile (loaded by the project's loaders; #3933) and a LiveView's owntemplate. See the components guide. -
A value written into
<textarea>,<title>or an HTML comment of a hybrid componenttemplateis escaped even when it is marked safe (#3914). The content of<textarea>and<title>is text in which character references are decoded, ended by the first matching end tag, and-->ends a comment the same way, so markup in a value there can end the element.Component.renderdraws a class through itstemplatewhen the crate constructor refuses a value (a lazy string is one) and always for a class with no crate counterpart, and that path left a marked-safe value alone:TextArea(value=<lazy marked-safe text that ends the textarea>)ended the element and put live markup after it. Now a{{ }}in that text is escaped whatever the value is marked as (a marked-safe string, a lazy one, an object whose text form is marked safe, a callable returning one; also{{ v|safe }}and under{% autoescape off %}). Before/after:<textarea>{{ v }}</textarea>withv = mark_safe("<b>x</b>")printed<b>x</b>and now prints<b>x</b>; the same for<title>and for<!-- {{ v }} -->. An ordinary value prints exactly as before: a plain name keeps the engine's own formatting (a date localized, a number formatted), checked by rendering a corpus (strings, numbers, decimals, dates, datetimes,None, booleans, lists, dicts) before and after; a dotted path or a filtered expression ends in|force_escape, so a date or number there prints in its canonical form (2026-01-02). A callable in such a slot is called where the engine reaches the slot, not before, so one in a branch that does not render is not called. Unchanged on purpose:<script>,<style>,<xmp>,<iframe>,<noembed>,<noframes>,<plaintext>and<noscript>keep the author's markup. Entities are not decoded in them, so HTML escaping is the wrong encoding (<script>var cfg = {{ cfg|safe }};</script>must stay JSON); use{{ data|json_script:"id" }}or adata-*attribute to put data in a page. The scanner still reads the first seven as raw text, so an<a href="{{ u }}">inside<xmp>is not an attribute. Every other text slot keeps a marked-safe value's markup, as before; the remaining difference to the Rust and custom paths for ordinary text slots is documented in the components guide and tracked in #3932. A{% blocktranslate %}block in an attribute value or in that text is no longer broken (see the #3913 fragment); a tag that prints text inside<textarea>/<title>/ a comment ({% firstof %},{% cycle %}), shapes the scanner cannot follow like a browser does (an element name built by a tag, an end tag in another block than its start) and an{% include %}d partial that holds slots are named by the existing once-per-class warning (class, tag/attribute/element names, shape names and a count; never a value). The scanner also reads a raw-text end tag the way the HTML tokenizer does (ASCII case and whitespace;</textarea-x>is not an end tag) and follows an element opened inside{% verbatim %}. -
An option whose value contains
&,<,>,"or'is drawn as selected (#3927).{% combobox %}decided "selected" by comparing the HTML-escaped option value with the raw selected value, in single mode (value=) and in multi mode (selected=), so a value such asa&bwas never drawn as selected (nocombobox-option-selectedclass,aria-selected="false") whileabwas. Both modes now compare the raw text of the option value and of the selection (asstr, so an integer1still matches"1"andNonestill matchesNone), and escape only what they write;a&bas a raw value stays a different value froma&b, and a marked-safe or lazy value is compared by its text, not by its marker. The same escaped-versus-raw comparison was in three other places, all fixed: the nativedj_selecthandler (selected), the nativedj_radiohandler (checked; the Django tag already compared raw, so the two engines disagreed), and the class-basedDependentSelect.dependent_select, the data-table select filter anddj_selecton the Django engine compare two values escaped the same way, or two raw ones, and are unchanged. -
The server-function and sticky-LiveView demo pages printed their own
template comments. Five multi-line{# #}comments inexamples/(Django's
{# #}is single-line only, so they rendered as text) are now
{% comment %}blocks.scripts/check-template-comments.pyscanned only
python/djust; it now scansexamples/too (--pathis repeatable) and
skipsstaticfiles/,node_modules/and virtualenvs. -
Per-model serialization settings are honoured for lazily wrapped model instances.
djust_exclude_fields,djust_serializable_fields,djust_serialize_sensitive_fields,to_dict()and the column-type rule are read from the model class a wrapper reports through__class__, on the native engine (a bare read, a dotted filter, a filtered rebinding), the{% load %}bridge, the eager serializer, the JIT attribute gate (which decides by name) and aModelFormover a wrapped instance. A wrapper that cannot be evaluated raises. A model that declares none of these settings renders as before. Regression cases inpython/tests/test_per_model_rules_lazy_wrapped_model.py. -
{% theme_icon %}no longer raises for a long digit string on an interpreter whosedecimalis the pure-Python_pydecimal(follow-up to #3766).theme_iconis documented to warn and never raise for a hostilesize, but asizeof 5000 digits raisedValueError: Exceeds the limit (4300 digits) for integer string conversionon the Python 3.15.1 CI cell (python/tests/test_theme_icon_size_3766.py, thevery-longrows) while passing on 3.12. The size text matched the validation pattern (it is all digits) andDecimal(<the digits>)was then built outside thetry; CPython's C_decimalparses digits itself, but_pydecimalbuilds the value withint(), which is subject tosys.get_int_max_str_digits(). The whole-number part of the size is now length-checked (leading zeros dropped, so"0016"is still16) before anyDecimalis built from it, and a failure fromDecimalanyway falls back to the default size under the existing guard. The same class indecode_state_roundtrip(python/djust/serialization.py): a user dict{"__djust_decimal__": "<5000 digits>"}that collides with the state tag now stays a dict, as its docstring promises, instead of raisingValueErroron a_pydecimalbuild. The new tests (python/tests/test_theme_icon_int_limit.py) force the pure-PythonDecimaland the digit limit, so they fail without the fix on every interpreter. -
URLs are normalised the way a browser reads them before the same-site test. The data table's row navigation (
data-table-row-click.js), the map picker's tile and asset URL (map_picker._plain_url,map-picker.js),validate_mount_urlandsame_origin_targetnow apply the browser's own URL normalisation before deciding whether a URL stays on the site, through one definition (djust.security.urls.url_as_parsedandurl_is_protocol_relative, mirrored in the two static scripts).same_origin_targetalso answers"/"for a URL its parser refuses, where it raised.url_allowed/url_attrare unchanged: they are a scheme allowlist, and their docstring now says so. Absolutehttp(s)row URLs are still followed. A differential over about 120,000 generated URLs checks the helper, the unchanged JavaScript guard and the Python decisions against the browser's URL parser and Django'surl_has_allowed_host_and_scheme.
Security
-
FormMixindoes not pre-fill password and other credential fields from the stored value. A field is withheld when it is on the serialization floor and looks secret-like: it has a password widget; or its name containspassword,passwd,secret,tokenorapikey, or one ofkey,hash,salt,pwd,passphrase,otp,signature,pin,credential,credentialsas a whole word (api_keyandpinCodematch,privatekeyandpwdo not); or its column is of a type the type floor excludes (asensitive_field_typesentry,BinaryField, an encrypted field type). A floor field that does not look secret-like (for exampleemaillisted inDJUST_SENSITIVE_FIELDS) and a custom column that is not on the floor (api_keywith the default configuration; list it inDJUST_SENSITIVE_FIELDSto include it) are seeded as before. A withheld field is seeded with""on mount,reset_form, a wizard step and after a valid submit, inform_dataand inas_live(),as_live_field(),{% live_field %}andget_field_value().{{ form }}and{% dj_form %}render as before.Behaviour. On an edit form an absent, empty, whitespace-only,
None,0,False,[]or{}submission of a withheld field keeps the stored column. A withheld password-widget field writes a typed value; every other withheld field is read-only (disabled, as before) and ignores typed input. APasswordInput(includingrender_value=True) is no longer echoed by the live renderers, and a hash-widget field that was notdisabledbecomes read-only (a one-time warning names the field). After a validUserCreationFormsubmitpassword1andpassword2are""in the form state, and theUserChangeFormpassword input is empty. A secret-like field with aninitialon a create form is not pre-filled in the live renderers. A validation message that carries a withheld field's stored value is replaced by "Enter a valid value."; other messages, such as a failed login or a password-policy message, are unchanged. AFormMixinview with no form class gets""for secret-like keys of its hand-managedform_datawhen state is restored. Saved form state from earlier versions is cleaned when it is restored. Regression cases inpython/tests/test_form_data_secret_seed.py. -
pulldown-cmarkis pinned to>= 0.13.6, past RUSTSEC-2026-0414 and RUSTSEC-2026-0433 (#3741, #3742). Both advisories (an exponential event blow-up from[[a|]], and quadratic work on nested![[wikilinks) affectpulldown-cmark0.13.0 through 0.13.5 and only withOptions::ENABLE_WIKILINKS.v1.3.0rc14shipped 0.13.4 in itsCargo.lockand SBOM;v1.3.0rc15andmainalready lock 0.13.6, so the weekly scan clears once it next reads a newer tag. djust is not reachable by either advisory:crates/djust_templates/src/markdown.rs::to_pulldownstarts fromOptions::empty()and sets only tables, strikethrough and task lists, never wikilinks. This change raises theCargo.tomlrequirement from0.13to0.13.6, so a lockfile regeneration cannot resolve below the patched version, and adds a Rust test thatOptions::ENABLE_WIKILINKSis unset and wikilink syntax stays literal text under everyRenderOptscombination. One behaviour difference between 0.13.4 and 0.13.6 reachesrender_markdown: a link reference definition followed by a whitespace-only line ([b]: /u\n\t\n) no longer renders an empty<p></p>. A differential of the two versions over the 401 markdown files in this repository (zero differences) and 200,000 generated inputs under all eight option combinations found that one class and nothing else. Regression casewikilink_syntax_is_never_a_linkincrates/djust_templates/src/markdown.rs. -
djust_themingjudged the URL a template prints, not the value it was handed (#3749). Atheme_nav/theme_nav_group/theme_breadcrumb/theme_sidebar_navitem whoseurlis a callable (def url(self): return "javascript:alert(1)") printedhref="javascript:...": the check ran before the template engine called the callable, judged"<bound method ...>", and the engine then printed what the method returned. The item's value is now looked up and resolved the way the engine does it:item["url"]first and the attribute second (for any object with a__getitem__, not only aMapping), then called once by the engine's own rules (do_not_call_in_templates,alters_data, a call that needs arguments), and the item is rewritten with a value the engine will not call or convert again; a callable that returns a callable is written back as the text the engine would have printed for it. A callableitemson a sidebar section is resolved the same way. A value is converted to text once, and that text is what is judged and printed (an object whose__str__answers differently the second time can no longer pass the check and print something else). A lazy value that prints as marked-safe text (lazy(lambda: mark_safe(...), str)()) is plain text like aSafeString.The
**attrspass-through oftheme_button/theme_input/theme_select/theme_progressjudged onlyhref/src/action/formaction. These are now also judged bydjust.components.utils.url_allowed:ping,srcset(entry by entry, so one bad entry does not drop the good ones),poster,data,background, and the framework's owndj-navigate/dj-patch/dj-patch-reload. A rejected scheme renders#and logs a warning, and these never raise (only the four that always did still do). The set is an explicit list decided name by name, not a pattern:cite,longdesc,manifest,codebase,usemap,icon,profile,archive,classid,itemid,itemtype(no browser follows them on the elements these tags print, or they hold text such asicon="mdi:home") and every author-defineddata-*/dj-*name (data-url,dj-action,data_location="Paris: France") are printed as written. One visible consequence:data="x:y",databeing the<object>fetch attribute the issue names, renders#likehref="x:y".What this covers, and what it does not: the tags' own arguments and the pinned pass-through names. A project template override of one of the theming component templates (the badge's, say) that prints an
attrs.<name>into a URL attribute is outside it, because a tag cannot see which key an override reads; no template in this repository does (the set of such reads is derived from the templates and pinned inTestAttrsReadsInUrlPositionsArePinned). Regression cases inTestCallableUrl,TestEveryPassthroughTagAndAttribute,TestThePassthroughSetIsPinnedinpython/tests/test_theming_url_hardening_3749.pyand inpython/tests/test_theming_url_review_3765.py; the sweep (scripts/template-library-url-sweep.py) drives every URL attribute the scanner reads through every tag that takes**attrs, so a new pass-through sink failstest_url_scheme_sweep_3665.pyuntil it is classified. -
{% theme_icon %},{% theme_form %},{% theme_form_errors %}and the inline theme switcher no longer write a raw value into markup they mark safe (#3766).theme_icon(name, size=20)built each icon with an f-string that interpolatedsizeand returned it undermark_safe, the bannedmark_safe(f'...')with an interpolated value:{% theme_icon 'check' size=u %}withu = '1" onload="alert(1)'printed<svg width="1" onload="alert(1)" ...>, an attribute injection wheneversizeholds user-influenced data (a literalsize=16was inert, which is why nothing showed it).sizeis now validated and the markup is built withformat_html. A number above zero and at most 1000, with at most three decimals, optionally followed bypx,em,rem,ex,ptor%in any case, is accepted; anint,floatorDecimalis rounded to three decimals first (so a computed14 * 1.1prints15.4), any other type is read by its text. Anything else renders the default size20and logs oneWARNINGper call (the value's type, never its content; an icon rendered in a loop logs once per icon), and does not raise: that includes a value whose__str__raises and an int over Python's 4300-digit limit, both of which used to propagate.size=Nonemeans "not given" and renders the default silently.theme_iconhas no Rust tag handler of its own (djust.theming.rust_handlersregisters thetheme_tagstags only), so both engines run this one function, and the tests drive it through both.What changes for a size that worked before: it prints what it printed when it is an int, or text of the form
16/1.5/24px/1.5remwith a lower-case unit. Deliberate differences: the number is re-printed from its parsed value (16.0prints16,"016"prints16,"1.50"prints1.5); a unit is lower-cased (16PXprints16px); afloatorDecimalis rounded to three decimals (Decimal("1E+2")prints100,0.1 + 0.2prints0.3); and a size outside the range now renders20with a warning (a number over 1000, such as1001or1234.5; zero or less; a float that rounds to zero, such as0.0001; a type whose text is not a plain number, such asFraction(3, 2)). Everything else a hostile or malformed value can be (a quote,>, an entity, whitespace, a sign, an exponent, abool, a very long string) renders20too.The same class, found in review where the first guard could not see it, because the value never sat inside
mark_safe(<f-string>):theme_formandtheme_form_errors(theme_form_tags.py) appended f-string lines holding the configuredcss_prefixandBoundField.id_for_label(built from the FIELD NAME, the formprefixandauto_id) to a list and marked the join safe, so a form with a field nameda" onfocus="alert(1)" x="printed<label for="id_a" onfocus="alert(1)" x="" class="theme-label">; every line is now a constant orformat_html. The{{ theme_switcher }}chunk (context_processors._render_theme_switcher, marked safe byLazyThemeHTML) printedpreset["name"]andpreset["display_name"]into<option value="...">unescaped; they are escaped now (&,<,>and"; the apostrophe is left alone, because both places sit between double quotes or in text and the built-in presetSynthwave '84keeps its bytes, so a name containing&prints&, which a browser shows unchanged)._passthrough_attrsjoinedname="value"parts built with an f-string;nameis validated first so it was inert, and it isformat_htmlnow with the same output. The gallery tooltip example (python/djust/theming/gallery/context.py, a constant from a literal tuple) isformat_html. A benign form renders byte for byte what it did (40 renderings across prefix, layout and form, captured before the change, are pinned inpython/tests/test_theme_markup_escaping_3766.py).The guard is
scripts/lib/safe_markup_taint.py, tested inpython/tests/test_safe_markup_guard_3766.py. It is name-based and single-function: it reads the syntax tree one function (or the module body) at a time, does no type inference, and never follows a call into another function. It follows a value through local variables, tuple unpacking,:=, containers (d[k] = ...,.append,.extend,.insert,+= [...]), list / dict / generator comprehensions,+=,join,%,+,.format,.format_mapandTemplate.substitute, to amark_safe/SafeString/SafeTextcall under any alias, or to the format string offormat_html. What counts as an escaper is decided by where the name comes from, not how it is spelled:escape,conditional_escape,format_htmlandformat_html_joinimported fromdjango.utils.html,html.escape, andint/float/bool/lenclean a value;re.escape,shlex.quote, anescapeimported from anywhere else, and a locally defineddef escape(v): return vdo not.min/max/round/abs/strof clean arguments,uuid.uuid4().hex,secrets.token_hex, an f-string field with a numeric format spec ({n:.2f}), anenumerateindex and.render(...)output (trusted by method name) are clean. It flags the pre-fixtheme_icon,theme_form,theme_form_errorsand_passthrough_attrs, which the earlier literal-shape check did not, and flags nothing underdjust/themingnow (the audited allowlist is empty).What it does not see, probed and pinned in
KNOWN_MISSESof its test: anything that crosses a function (a string built in one function and marked safe in another, a module global appended to in one function and joined in another, a value stored onself, a helper that returns an interpolated string);mark_safe(param); a sink reached without a call to it by name (partial(mark_safe, ...),map(mark_safe, ...),getattr(safestring, "mark_safe")(...));functools.reduceover raw items; and any builder not listed above. In particular the inline theme switcher (preset_options += f"..."inside_render_theme_switcher, marked safe byLazyThemeHTMLelsewhere) is protected by its own tests inpython/tests/test_theme_markup_escaping_3766.py, not by the guard.build_theme_head_contextbuilds the<style>/<link>blocks thatThemeMixinmarks safe; that was read, not machine-checked. -
A tag rendered through the
{% load %}bridge no longer walks raw objects past the serialization floor (#3771).{{ obj.user.password }}has always been empty under the Rust engine, because the engine's walk re-protects after every segment of a path. A bridged tag (relative_time,avatar_groupand the other object-taking djust component tags, every third-partysimple_tag/inclusion_tag/ raw@register.tag,{% blocktranslate %},{% cache %}) resolves its operands in Django's own node withVariable._resolve_lookupover the raw objects of the handler dict, and nothing re-protected those steps:{% relative_time datetime=obj.user.password %}printed the password hash, and so didform.instance.password,form.fields.owner.queryset.first.password,obj.users.first.passwordand, since #3770 hands a bridged tag a liveBoundField,{% for f in form %}…f.form.instance.password. The leaf floor only wrapped aModel/Manager/QuerySetthat was itself the root of the path, so aForm, aBoundFieldor a plain object holding a model passed through.The node stays Django's. What changes is the walk: while a bridged node renders, every
Variableit resolves against theContextthe bridge built (FilterExpressionoperands,|filter:argument, and thetemplate.Variablea raw tag constructs itself, at compile or at render time) is resolved by Django's own_resolve_lookupover a context whose lookups return floor-checked wrappers, and the next step is re-protected after every segment, including after a floor proxy: a stock queryset method or a model property that returns a plain container of models (qs.in_bulk.<pk>.password,obj.user.friends_prop.0.password,obj.user.get_friends.0.password) hands Django a container, and the model inside it is protected on the step that reaches it. This is the re-protectionContext::walk_livedoes for{{ }}, applied to the steps Django's resolver takes; it is not a second implementation of that walk, and the two can differ for an exotic object (see below). The value the walk ends on is released: a wrapper is unwrapped; a model reached on the way is the floor-enforcing proxy; a container that holds a model is re-protected like a top-level container; anything else, including a container with no model in it, arrives as the caller's own object. The refused path is "missing", as in{{ }}: an unresolved operand is the empty string and a filter argument that does not resolve raisesVariableDoesNotExist, exactly as Django treats any missing variable. The gate is exact (theContextregistered by the bridge for that node, per thread and task), so a Django engine in the same process, another thread, and theContext.new()an inclusion tag renders its own template on are untouched. Benign output is unchanged (the walker is transparent tocallable,do_not_call_in_templates,alters_data,silent_variable_failure, signature binding and a property that raisesAttributeError).Behaviour change. A model reached by a path now arrives in a tag as the floor proxy (as a top-level
{% tag user=user %}always did) rather than the raw instance. Soisinstance(operand, User),operand._meta,operand.save()and an ORM filter on the instance no longer work on a path-spelled operand, exactly as they do not on a top-level one. Equality did flip silently ({% t obj.user obj.user %}and{% t request.user obj.user %}compared two distinct proxies and went from equal to unequal); the proxy's__eq__/__hash__now delegate to the model behind it, which reveals identity and no field, so those comparisons are as they were.isinstancecannot be restored without making the proxy aModel, which would let_protect_sidecar_valuetreat it as a model to wrap again, so it is not attempted.What this does not floor (the template author's reach through the tag author's Python): a generic accessor tag that takes a path as a string (
{% getattr obj "user.password" %},get_item,call_method) receives a released plain object and walks it in its own code; atakes_contexttag readingcontext["obj"]itself; a tag function's body, which receives its released operands; an inclusion tag's own template, rendered onContext.new(); a node that never usesVariable; and an operand released as a non-list/tuple/dict/setiterable (a dict view, a generator, aRawQuerySet), which arrives as the raw object. Whether a particular third-party tag is one of the unfloored shapes is the tag author's code, not something the engine can see.Regression cases in
python/tests/test_bridge_floor_3771.py(the five spellings from the issue and their neighbours, under ten operand shapes, for an inline tag, a raw tag that builds its ownVariable, a raw-block tag and a cache vary-on operand, throughDjustTemplateBackend,LiveViewTestClient, an HTTP GET and a WebSocket mount); each mechanism is gate-off tested separately. The code ispython/djust/template_bridge_floor.py; the three places the bridge renders a node (_render_node, the raw-block handler, the cache operand) enter its scope.install()is one assignment of a constant and reads nothing back, so it cannot lose a race. -
A template that named a mutating callable ran it:
{{ users.delete }}over a QuerySet deleted every row underDjustTemplateBackendand in a LiveView (#3772). Django refuses, becauseModel.deleteandQuerySet.deletecarryalters_data = Trueand the engine never calls such a callable. The refusal existed where the Rust engine walks an object (Context::maybe_call), and_rust.render_template, which has no queryset step, was never affected. The call was made one step earlier. The Rust queryset serializer (serialize_object_with_pathsincrates/djust_live/src/lib.rs, behindDjustTemplateBackend.renderand the LiveView queryset JIT) walks the paths a template names and called every callable it found there with noalters_data/do_not_call_in_templatescheck; the Python codegen twin already had the check. So{{ users.delete }},{% for u in users %}{{ u.delete }}{% endfor %},{{ u.save }},{{ u.groups.clear }},{{ u.groups.create }},{{ g.user_set.clear }}and{{ g.user_set.create }}each ran against the real rows. The reachable shapes are a QuerySet's rows and the related managers reached through them. A single model ({{ user.delete }}), alist[Model]and a top-level manager were already inert and stay so; they are kept in the test matrix as controls.The rule is now stated once, in
djust_core::template_call_guard(do_not_call_in_templatesfirst, thenalters_data, Django's order), and both walkers ask it: the sidecar walk and the queryset serializer. A refused name is left out of the serialized row, exactly as a name the serialization floor denies is, so the template renders empty. The serializer's.all()call on a to-many attribute goes through the same guard. A callable with neither marker is still called ({{ u.get_username }},{{ u.groups.count }}), as Django calls it. The order of the two reads is Django's: a callable carrying both markers is used as it is and prints as its repr, it is not refused asalters_data; that order is pinned incrates/djust_core/tests/test_template_call_guard_3772.rsand inTestTheMarkerOrderIsDjangos3772.Not changed: the fixed-name calls in
serialize_python_value(render()on a component, fourget_*names on a model), which a template author does not choose. Regression cases inpython/tests/test_alters_data_toplevel_3772.py: every shape runs against a real table throughDjustTemplateBackend, a LiveView and_rust.render_template, with Django's own engine as the control and a check that the detector sees a direct mutation. -
A context key named
__djust_typed_args__no longer reaches a template tag handler (#3773).json_viewer(and any handler that declaresTYPED_ARGS = True) is handed its resolved operands under that reserved key. The engine put the key in only when the tag had a resolved operand, so for a tag whose operands were all quoted literals ({% json_viewer data="x" class="plain" %}) a top-level context key of that name, supplied by the application, stood in for the engine's value and replaceddataandclass. The output stayed escaped, so this was not an XSS; it was an application value overriding an engine-to-handler channel. The key is now the engine's alone: it is removed from the flat context map, from the raw-Python objects laid over it, and from the live frames a bridged tag reads, before any handler is called, and the engine puts its own value back only for a handler that declaresTYPED_ARGS. A user value of any shape (a dict, a string, a list, a number,None, an object that cannot cross) no longer reaches an inline, block, assign or bridged handler. Regression cases inpython/tests/test_typed_args_reserved_key_3773.py; each of the four removals is gate-off tested separately. -
An HTML attribute NAME built from a template argument could carry markup (#3774).
djust._html.build_tagescaped every attribute value but wrote the name as given, so{% live_input "text" handler="h" event=var %}withvar = "dj-input onfocus=alert(1) autofocus y"rendereddj-input onfocus=alert(1) autofocus y="h". Names are now checked against the HTML rule (no whitespace, control, quote,<,>,/or=) by the newdjust._html.attr_name, whichbuild_tagandFormMixin.as_live'sBaseAdapter._build_tagshare; an invalid name raisesValueError. The writers that did not go throughbuild_tagnow call it too:render_icon's extra attributes (the name was onlyhtml.escaped, which cannot make one safe), thedata-*keys ofButton,CardandPaginationComponent, and the trigger anddj-value-*keys ofLiveComponent.event_attrs. Every real name (dj-input,aria-label,@click,dj-keydown.enter) renders exactly as before. Found by auditing the 125mark_safesites the safe-markup guard flags outsidedjust.theming: none of those was vulnerable. -
_rust.serialize_contextranrender()on any object and fourget_*methods on any model, with no call guard, and shippedpassword,is_superuserandis_staffof a model (#3778). The Rust function (serialize_python_valueincrates/djust_live/src/lib.rs) is a public#[pyfunction]that no production path calls: agit grepoverpython/djust,examples,benchmarksandscriptsfinds only the Python helper of the same name indjust.template.serialization(a different function), every importer ofdjust._rust.serialize_contextis a test, and a spy on it records zero calls across aDjustTemplateBackendrender and a LiveView mount. So the exposure was a caller who hands it an object, not a template author. Reproduced first against the real call path: arendercarryingalters_datastill ran, aget_usernamemarkedalters_datadeleted the row (User.objects.count()1 -> 0),get_absolute_urlmarkeddo_not_call_in_templatesran, and the model's dict containedpassword,is_superuserandis_staffbecause the arm built it withmodel_to_dictand never asked the serialization gate (the oneserialize_querysetasks since #2685). The issue named the calls; the floor bypass was found by running it.Both arms now follow the rule the queryset serializer follows. Every call goes through
djust_core::template_call_guard(#3772): arenderorget_*that carriesdo_not_call_in_templatesoralters_datais not called, a refusedrenderfalls back tostr(obj)and a refusedget_*is left out. Every model field and method name goes through the serialization gate (djust.optimization.codegen.emittable_names), so the floor,DJUST_SENSITIVE_FIELDSand the per-model allow and deny lists apply, and a name it refuses is dropped before the method behind it can be called. If the gate is missing or raises the model isstr(obj); if the gate fails internally it permits no names and the model ships as an empty dict. It is never an ungated dict. The gate call is now shared with the queryset serializer (permitted_among), not copied. A callable with neither marker is still called, so the output of an ordinary model and component is unchanged.Decision: guarded, not deleted. The function is listed in
_rust.pyianddocs/TYPE_STUBS.md, so removing it is a public-surface call for a maintainer; with no production caller, deleting it later is a pure removal and is tracked as the follow-up on #3778. The stub also declared afield_pathsargument the function never took; it now matches the signature and describes the guard. Regression cases inpython/tests/test_serialize_context_guards_3778.py: a component whoserenderdeletes rows, each of the fourget_*names under each marker, the floor fields, a configured sensitive field, a per-model allowlist and exclusion, a nested model at every container depth, and a gate that raises. -
LIVEVIEW_CONFIG['theme']['css_prefix']is validated when the config is read; a prefix that holds markup can no longer break out of the theme<style>blocks (#3779).get_css_prefix()returned the raw config string. It reachedgenerate_component_css,generate_css_for_stateand the critical-CSS split, which print it into.{prefix}btn { ... }inside<style>blocks thatpython/djust/theming/templates/djust_theming/theme_head.htmlrenders under{% autoescape off %}.LIVEVIEW_CONFIG = {"theme": {"css_prefix": "x</style><img src=x onerror=alert(1)>"}}printed a parsed<img onerror>element in{% theme_head %},{% theme_css %}and{{ theme_head }}(the context-processor chunk). This is settings-level, not request-reachable: it needs a hostile or careless value in settings, which is why nothing had shown it.cookie_namespacewas regex-validated anddirectionallow-listed;css_prefixwas neither.get_theme_config()now raisesImproperlyConfiguredfor acss_prefixthat is not a string of ASCII letters, digits, underscores and hyphens ([A-Za-z0-9_-]*), the way it already does forcookie_namespace.""(the default) andNonestill load. Every reader goes through that function (theme_head,theme_css, the context processor, the CSS views,ThemeMixin,theme_form/theme_form_errors, the component and page tags,get_css_prefix), so one check covers them all, in both the Django and the Rust template engines. It is an exception at load rather than only a system check becausemanage.py checkdoes not run undergunicornoruvicorn: a check-only gate would leave the injection live in production. The three generators that also take a prefix as an ARGUMENT, bypassing the config (generate_component_css,CompleteThemeCSSGenerator,DesignSystemCSSGenerator), run the same validator, sogenerate_css_for_state(state, css_prefix=...),generate_critical_css_for_state,generate_deferred_css_for_stateandgenerate_theme_cssare c