Added
Form & submit polish batch (v0.5.1 P2) — Three related form-UX primitives:
dj-no-submit="enter"— Prevent Enter-key form submission from text inputs. Fixes the #1 form UX annoyance where pressing Enter to confirm a field accidentally submits the whole form. Textareas (multi-line input), submit-button clicks, and modified keys (Shift+Enter, Ctrl+Enter) are unaffected. Supports comma-separated modes (currently only"enter") for future expansion. Document-level keydown listener — DOM morphs don't need re-registration. (python/djust/static/djust/src/34-form-polish.js)dj-trigger-action+self.trigger_submit(selector)— Bridge successful djust validation to a native HTML form POST. Essential for OAuth redirects, payment gateway handoffs, and anywhere the final step needs a real browser POST. The server callsself.trigger_submit("#form-id")after validation passes; the client receives the push event, verifies the target form carriesdj-trigger-action(explicit opt-in — refusal is logged in debug mode), and calls the form's native.submit(). (python/djust/mixins/push_events.py,python/djust/static/djust/src/34-form-polish.js)dj-loading="event_name"shorthand — Declarative scoped loading indicator:<div dj-loading="search">Searching...</div>shows only while thesearchevent is in-flight. Previously required combiningdj-loading.show+dj-loading.for="event_name"with an inlinestyle="display:none". The shorthand auto-hides the element on register (no inline style required) and treats the attribute value as both the event-scope and the implicit.showtrigger. Coexists with the existingdj-loading.*modifier family. (python/djust/static/djust/src/10-loading-states.js)
Tests: 11 JS test cases in
tests/js/form_polish.test.jscovering every happy path and failure mode; 4 Python tests inpython/djust/tests/test_trigger_submit.pylocking in the push-event shape. Client.js: 35 → 36 source modules (+~120 LOC JS, +~30 LOC Python). Scoped scoped-loadingdj-loading="event"implementation reuses existingglobalLoadingManagerinfrastructure — no duplication.State & computation primitives batch (v0.5.1 P2) — Four small related primitives for derived state, dirty tracking, stable IDs, and cross-component context sharing:
- Memoized
@computed("dep1", "dep2")—@computednow accepts an optional tuple of dependency attribute names. When given, the value is cached on the instance and only recomputed when any dep's identity or shallow content fingerprint changes (id + length + key subset matching_snapshot_assignssemantics). Plain@computed(no args) retains property semantics — recomputes every access. ReactuseMemoequivalent. (python/djust/decorators.py) - Automatic dirty tracking —
self.is_dirty/self.changed_fields/self.mark_clean()— Track which public view attributes have changed since a baseline captured aftermount().changed_fieldsreturns a set of attr names that differ from the baseline;is_dirtyisbool(changed_fields);mark_clean()resets the baseline (call after a successful save). Use cases: "unsaved changes" warnings (beforeunload), conditional save buttons, optimizedhandle_eventthat skips work when nothing changed. Respectsstatic_assignsand ignores private attrs. The WebSocket consumer and the HTTP API dispatch view both capture the baseline after mount. (python/djust/live_view.py,python/djust/websocket.py,python/djust/api/dispatch.py) - Stable
self.unique_id(suffix="")— React 19useIdequivalent. Returns a deterministic per-view ID stable across renders of the same logical position. Useful foraria-labelledby, form field IDs, and any element that needs a consistent identifier across re-renders. Format:djust-<viewslug>-<n>[-<suffix>]. Counter resets viareset_unique_ids()at render boundaries. (python/djust/live_view.py) - Component context sharing —
self.provide_context(key, value)/self.consume_context(key, default=None)— React Context API equivalent. A parent view or component exposes a value underkey; descendants look it up withconsume_context, walking the_djust_context_parentchain. Scoped per render tree;clear_context_providers()resets. (python/djust/live_view.py) Seedocs/website/guides/state-primitives.md.
- Memoized
Auto-generated HTTP API from
@event_handler(v0.5.1 P1 HEADLINE, ADR-008) — Opt-in@event_handler(expose_api=True)exposes a handler atPOST /djust/api/<view_slug>/<handler_name>/with an auto-generated OpenAPI 3.1 schema served at/djust/api/openapi.json. Unlocks non-browser callers (mobile, S2S, CLI, AI agents) without duplicating business logic — the HTTP transport is a thin adapter over the existing handler pipeline, reusingvalidate_handler_params(),check_view_auth(),check_handler_permission(), and the same_snapshot_assigns()/_compute_changed_keys()diff machinery the WebSocket path uses. One stack, one truth (manifesto #4). New packagedjust.apiwithDjustAPIDispatchView(dispatch view),api_patterns()(URL factory),OpenAPISchemaView(schema endpoint),SessionAuth+ pluggableBaseAuthprotocol (auth classes may opt out of CSRF viacsrf_exempt = True), and a registry that walksLiveViewsubclasses with exposed handlers.LiveViewgains two read-only contract attributes:api_name(stable URL slug) andapi_auth_classes(auth class list). Response shape mirrors the WS assigns-diff:{"result": <return>, "assigns": {<changed public attrs>}}. Error shapes are structured witherror/message/details— 400 validation, 401 unauth, 403 denied or CSRF fail, 404 unknown view/handler or handler notexpose_api=True, 429 rate limit, 500 handler exception (exception messages logged server-side only, never leaked to the client). Rate limiting: HTTP uses a process-level LRU-capped token bucket keyed on(caller, handler_name)honoring the handler's@rate_limitsettings; WebSocket continues to use its per-connectionConnectionRateLimiter. The two transports share rate/burst values but separate bucket storage — a caller using both draws from both independently (a shared-bucket refactor is tracked as a follow-up).manage.py djust_auditnow lists everyexpose_api=Truehandler and flags any missing@permission_required— treat an exposed handler like@csrf_exempt. Out of scope per ADR-008: streaming responses, GraphQL batching, first-party token auth, Swagger UI hosting, per-handler URL customization. Full guide atdocs/website/guides/http-api.md. (python/djust/api/,python/djust/decorators.py,python/djust/live_view.py,python/djust/management/commands/djust_audit.py)Service worker core improvements — instant page shell + WebSocket reconnection bridge (v0.5.0 P3, opt-in) — Two independent SW features that close the v0.5.0 milestone. Both are OFF by default; users opt in explicitly via
djust.registerServiceWorker({ instantShell: true, reconnectionBridge: true })from their own init code. No auto-registration.- Instant page shell. The SW caches the first navigation's response split into a "shell" (everything outside
<main>) and "main" (inside). Subsequent navigations serve the cached shell immediately with a<main data-djust-shell-placeholder="1">placeholder; the client then fetches the current URL withX-Djust-Main-Only: 1and swaps in the fresh<main>contents. Shell/main split uses a single non-greedy regex — nested<main>inside HTML comments or</main>insideCDATAare documented limitations (full HTML parser deferred). Server side honors the header via the newdjust.middleware.DjustMainOnlyMiddleware, which extracts the first<main>…</main>inner HTML, updatesContent-Length, and stampsX-Djust-Main-Only-Response: 1. The middleware only touches HTML responses; JSON / binary / streaming responses pass through unchanged. Ordering-safe — it can sit anywhere inMIDDLEWAREthat sees the rendered response. - WebSocket reconnection bridge. Client-side wraps
LiveViewWebSocket.sendMessageso that whenws.readyState !== OPENthe serialized payload is posted to the SW viapostMessage({type: 'DJUST_BUFFER', connectionId, payload})instead of being dropped. The SW stores messages in an in-memoryMapkeyed by connection id, capped at 50 per connection (oldest dropped). On reconnect the client firesDJUST_DRAIN; the SW returns the buffered payloads and the client replays each viaws.ws.send(). Per-page-load connection ids isolate buffers across tabs. IndexedDB persistence and server-side sequence-dedup replay are deferred to v0.6 (best-effort replay today). - Files:
python/djust/static/djust/service-worker.js(new, standalone — NOT bundled intoclient.js),python/djust/static/djust/src/33-sw-registration.js(new, concatenated intoclient.js),python/djust/middleware.py(new),python/djust/config.py(newservice_workerdefaults sub-dict), tests intests/js/service_worker.test.js(10 cases) andtests/unit/test_main_only_middleware.py(7 cases), full guide atdocs/website/guides/service-worker.md.
- Instant page shell. The SW caches the first navigation's response split into a "shell" (everything outside
UploadWriter— raw upload byte-stream access for direct-to-S3 / GCS streaming (Phoenix 1.0 parity, v0.5.0 P2) — NewUploadWriterbase class indjust.uploadswith anopen()→write_chunk(bytes)→close() -> Any/abort(error)lifecycle, wired intoallow_upload(name, writer=MyWriter). When a writer is configured, binary WebSocket chunks are piped straight to the writer without buffering to disk or RAM — zero temp file, zeroentry._chunks. Writers are instantiated lazily per upload on the first chunk (so abandoned uploads never open an S3 multipart upload), opened exactly once, fedwrite_chunk()per client frame, and finalized viaclose()whose return value is stored onUploadEntry.writer_resultand rendered in the upload-state context as{{ entry.writer_result }}. Any failure (open or write_chunk raised,close()raised, size-limit exceeded, client cancelled, WebSocket disconnected viaUploadManager.cleanup()) routes throughabort(BaseException)with the raw exception so writers can release server-side resources (e.g.AbortMultipartUpload);abort()is wrapped to swallow its own exceptions so a failing S3 cleanup never propagates into the request path. IncludesBufferedUploadWriterhelper that accumulates client-sent 64 KB chunks until a configurablebuffer_threshold(default 5 MB — S3 MPU minimum part size except for the last) and callson_part(bytes, part_num)so subclasses work with S3-aligned parts without worrying about raw client chunk size. Legacy (no-writer=) disk-buffered path is untouched byte-for-byte — backward compatible. Documented indocs/website/guides/uploads.mdwith a full S3 multipart example. (python/djust/uploads.py,python/djust/websocket.py)dj-ignore-attrs— per-element client-owned attribute opt-out (Phoenix 1.1JS.ignore_attributes/1parity, v0.5.0 P2) — Mark specific HTML attributes as client-owned so VDOMSetAttrpatches skip them.<dialog dj-ignore-attrs="open">prevents the server from resetting theopenattribute that the browser manages;<div dj-ignore-attrs="data-lib-state, aria-expanded">protects third-party JS state. Comma-separated list with whitespace tolerance. The guard sits insideapplySinglePatch'scase 'SetAttr'after theUNSAFE_KEYScheck; the attribute write is skipped entirely (andbreaks out of the case) when the element opts out.RemoveAttris intentionally unaffected. Implementation:globalThis.djust.isIgnoredAttr(el, key)helper (~20 lines JS) plus a three-line check in the patch site. (python/djust/static/djust/src/31-ignore-attrs.js,python/djust/static/djust/src/12-vdom-patch.js){% colocated_hook %}template tag + runtime extraction (Phoenix 1.1ColocatedHookparity, v0.5.0 P2) — Write hook JavaScript inline alongside the template that uses it, instead of in a separate file.{% colocated_hook "Chart" %}hook.mounted = function() { renderChart(this.el); };{% endcolocated_hook %}emits a<script type="djust/hook" data-hook="Chart">tag with a/* COLOCATED HOOK: Chart */auditor banner. The client runtime walksscript[type="djust/hook"]elements on init and after each VDOM morph (reinitAfterDOMUpdate), registers each body aswindow.djust.hooks[name]vianew Function, and marks the script withdata-djust-hook-registered="1"so re-scans are idempotent. Optional namespacing viaDJUST_CONFIG = {"hook_namespacing": "strict"}prefixesdata-hookwith<view_module>.<view_qualname>so two views can each defineChartwithout colliding; per-tag opt-out with{% colocated_hook "X" global %}. Namespacing is OFF by default for compat. Security: the body is template-author JS (same trust level as any other template JS);</script>/</SCRIPT>are escaped in the tag'srender()to prevent premature tag close. Apps on strict CSP without'unsafe-eval'should continue using the traditional registration pattern. (python/djust/static/djust/src/32-colocated-hooks.js,python/djust/templatetags/live_tags.py,python/djust/config.py,docs/website/guides/hooks.md)Database change notifications — PostgreSQL
LISTEN/NOTIFY→ LiveView push (v0.5.0 P1) — Subscribe LiveViews to Postgres pg_notify channels so database changes push real-time updates to every connected user with zero explicit pub/sub wiring. Three APIs:@notify_on_save(channel="orders")model decorator hooks Djangopost_save/post_deleteand emitsNOTIFY <channel>, <json>;self.listen("orders")inmount()subscribes the view (joins a Channels group nameddjust_db_notify_<channel>);def handle_info(self, message)receives{"type": "db_notify", "channel": ..., "payload": {"pk": ..., "event": "save"|"delete", "model": "app.Model"}}and re-renders via the standard VDOM diff path. A process-widePostgresNotifyListenerowns one dedicatedpsycopg.AsyncConnection(outside Django's pool — long-lived LISTEN connections don't play nice with pgbouncer transaction pooling) and runsasync for notify in conn.notifies():, bridging every NOTIFY intochannel_layer.group_send(...). Channel names are strictly validated (^[a-z_][a-z0-9_]{0,62}$) at registration and listen time — load-bearing because Postgres NOTIFY doesn't accept bind parameters for the channel identifier.send_pg_notify(channel, payload)is a public helper for Celery tasks / management commands. Non-postgres backends no-op gracefully (debug-logged);self.listen()raisesDatabaseNotificationNotSupportedwhen psycopg or a postgres backend isn't available. Known limitation: notifications emitted while the listener's TCP connection is dropped are lost — listener auto-reconnects with 1s backoff and re-issues LISTEN for all subscribed channels, and WSmount()re-fetch handles the client-side recovery case. Documented indocs/website/guides/database-notifications.md. (python/djust/db/decorators.py,python/djust/db/notifications.py,python/djust/mixins/notifications.py,python/djust/websocket.py)PyO3
getattrfallback for model attribute access (v0.5.0 P1 — Rust template engine parity) — Templates can now reference Django model instances passed through context without manual dict conversion.{{ user.username }}resolves via Pythongetattrwhenuseris a raw Python object rather than a JSON-serialized dict. Implementation: Python's_sync_state_to_rust()builds a sidecar of non-JSON-friendly context values and forwards them via the newRustLiveView.set_raw_py_values()method; Rust'sContext::resolve()tries the normal value-stack path first, then walksgetattron attached PyObjects one segment at a time.PyAttributeError(and any property-descriptor exceptions) are caught — missing attrs render as empty, matching Django'sTEMPLATE_STRING_IF_INVALIDdefault.ValuestaysSerialize-friendly (noValue::PyObjectvariant); sidecar lives outside the Value enum viaArc<HashMap<String, PyObject>>onContext. (crates/djust_core/src/context.rs,crates/djust_live/src/lib.rs,python/djust/mixins/rust_bridge.py)register_assign_tag_handler()for context-mutating template tags (v0.5.0 P1 — Rust template engine parity) — New tag-handler variety complementingregister_tag_handler(emits HTML) andregister_block_tag_handler(wraps content). An assign tag'srender(args, context)method returns adict[str, Any]that's merged into the template context for subsequent sibling nodes — no HTML output. Enables{% assign slot var_name %}-style patterns. Supported inside{% for %}loops (per-iteration mutation). Registered viadjust._rust.register_assign_tag_handler(name, handler). NewNode::AssignTagvariant; partial-renderer emits"*"wildcard dep so downstream nodes always re-render on context changes. (crates/djust_templates/src/registry.rs,crates/djust_templates/src/parser.rs,crates/djust_templates/src/renderer.rs) Seedocs/website/guides/template-cheatsheet.md.dj-virtual— Virtual / windowed lists with DOM recycling (v0.5.0 P1) — Render only the visible slice of a large list, recycling DOM nodes as the user scrolls.<div dj-virtual="items" dj-virtual-item-height="48" dj-virtual-overscan="5" style="height: 600px; overflow: auto;">keeps ~visible-plus-overscan children in the DOM even if the pool has 100K entries. Implementation: fixed-height windowing viatransform: translateY(...)on an inner shell plus a hidden spacer for scrollbar length, scroll handler batched throughrequestAnimationFrame, real element identity preserved across scrolls for hook/framework compatibility. Integrates with the VDOM morph pipeline: new containers are picked up byreinitAfterDOMUpdate, anddjust.refreshVirtualList(el)is available for explicit repaints.djust.teardownVirtualList(el)disconnects observers for unmounted containers. (python/djust/static/djust/src/29-virtual-list.js) Seedocs/website/guides/large-lists.md.dj-viewport-top/dj-viewport-bottom— Bidirectional infinite scroll (Phoenix 1.0 parity, v0.5.0 P1) — Fire server events when the first or last child of a stream container enters the viewport viaIntersectionObserver.<div dj-stream="messages" dj-viewport-top="load_older" dj-viewport-bottom="load_newer" dj-viewport-threshold="0.1">. Once-per-entry firing (matches Phoenix) via adata-dj-viewport-firedsentinel; calldjust.resetViewport(container)or replace the sentinel child to re-arm. New server-sidestream()limit=Nkwarg andstream_prune(name, limit, edge)method emit astream_pruneop that trims children from the opposite edge so chat apps, activity feeds and log viewers can stream bidirectionally without unbounded DOM growth. (python/djust/static/djust/src/30-infinite-scroll.js,python/djust/static/djust/src/17-streaming.js,python/djust/mixins/streams.py)assign_async/AsyncResult(v0.5.0 P1) — High-level async data loading inspired by Phoenix LiveView'sassign_async. Callself.assign_async("metrics", self._load_metrics)inmount()(or any event handler); the attribute is set toAsyncResult.pending()immediately, the loader runs via the existingstart_asyncinfrastructure, and on completion the attribute becomesAsyncResult.succeeded(result)orAsyncResult.errored(exc). Templates read the three mutually-exclusive states via{% if metrics.loading %}…,{% if metrics.ok %}{{ metrics.result }}…,{% if metrics.failed %}{{ metrics.error }}…. Sync andasync defloaders are both supported; multiple calls in the same handler load concurrently. Cancellation piggybacks oncancel_async("assign_async:<name>"). (python/djust/async_result.py,python/djust/mixins/async_work.py){% dj_suspense %}block tag for template-level loading boundaries (v0.5.0 P1) — Declarative counterpart toassign_async: wrap a section depending on one or moreAsyncResultassigns, and the boundary emits a fallback while any are loading, an error div if any failed, or the body once all areok. Explicitawait="metrics,chart"syntax keeps the tag debuggable — no reflection magic. Fallback templates are loaded via Django's template loader; unspecified fallbacks render a minimal spinner. Nested suspense boundaries resolve independently. Registered alongside{% call %}in the Rust template engine — no parser/renderer changes. (python/djust/components/suspense.py,python/djust/components/rust_handlers.py) Seedocs/website/guides/loading-states.md.Function components via
@componentdecorator (v0.5.0 P1 batch) — Stateless Python render functions registerable as template-invokable components.@component def button(assigns): ...is callable from templates via{% call "button" variant="primary" %}Go{% endcall %}(with{% component %}as a synonymous alias). Closes the middle ground between raw HTML and fullLiveComponentclasses for the ~80% of UI pieces (buttons, cards, badges, icons) that are stateless.clear_components()helper exposed for tests. (python/djust/components/function_component.py,python/djust/__init__.py)Declarative component assigns and slots (Phoenix.Component parity) —
Assign("variant", type=str, default="default", values=["primary", "danger"], required=True)andSlot("col", multiple=True)DSL, declared on aLiveComponentclass attribute (assigns = [...]/slots = [...]) or on function components via@component(assigns=[...], slots=[...]). Validation runs at mount/invoke: required-missing raisesAssignValidationErrorin DEBUG and warns in production, type coercion (str → int / bool / float) is automatic, enum violations viavalues=raise. Child-classassignsextend (and override by name) parent declarations via MRO walk. (python/djust/components/assigns.py,python/djust/components/base.py) Seedocs/website/guides/components.md.Named slots with attributes via
{% slot %}/{% render_slot %}tags — Parent templates pass named content blocks with attributes into components:{% call "card" %}{% slot header label="Title" %}Header{% endslot %}Body{% endcall %}. Multiple same-name slots collect into a list (essential for table columns, tab panels). Slots are exposed to the component asassigns["slots"] = {name: [{"attrs": {...}, "content": "..."}, ...]}. Non-slot content in the{% call %}body becomeschildren/inner_block. Implemented in pure Python via a sentinel-and-extract protocol — zero Rust parser/renderer changes. (python/djust/components/function_component.py)
Fixed
- Attribute-context HTML escaping parity with Django (v0.5.0 P1 — Rust template engine parity) — Variables inside HTML attribute values now route through a dedicated
html_escape_attr()that's guaranteed to escape"→"and'→'(in addition to&/</>). Detection reuses the existingis_inside_html_tag_at()parser helper — the per-Node::Variablein_attrflag is computed at parse time, so renderer cost is a bool check.|safestill bypasses escaping in both attribute and text contexts. Today's behaviour is unchanged (the basehtml_escapealready covered quotes) — this refactor makes the parse-time classification visible to the renderer so future changes to the default escape can't accidentally break attribute values like<a href="{{ url }}">whenurlcontains quotes. (crates/djust_templates/src/parser.rs,crates/djust_templates/src/filters.rs,crates/djust_templates/src/renderer.rs) - Inline conditional
{{ x if cond else y }}now contributes deps to enclosing wrappers (#783, sibling bug) — Same failure mode as nested{% include %}:extract_from_nodeshad no arm forNode::InlineIf, so itstrue_expr/condition/false_exprvariables were silently dropped from the dep set of any surrounding{% if %}/{% for %}/{% with %}. Changing the condition alone (e.g.step_activein{% for s in steps %}<span class="{{ 'active' if step_active else 'idle' }}">) producedpatches=[]and stale HTML. Fix:extract_from_nodesnow extracts non-literal variables from all threeInlineIfexpressions. - Nested
{% include %}now propagates wildcard dep to enclosing wrappers (#783) — Rust partial renderer reused the cached fragment of an{% if %}/{% for %}/{% with %}wrapping a nested{% include %}, becauseextract_from_nodestreatedIncludeas having no variable references. When the included template referenced a context key that changed (e.g.{{ field_html.first_name|safe }}), the wrapper's dep set ({current_step_name}) did not intersectchanged_keys({field_html}),needs_renderreturnedfalse, the cached HTML was reused, and the text-region fast-path compared byte-identical old/new HTML →patches=[]withdiff_ms: 0. Manifested with deeply-nestedWizardMixintemplates ({% extends %} → {% block %} → {% if current_step_name == "..." %} → {% include "step_*.html" %}). Fix:extract_from_nodesnow injects"*"into the variables map when it encounters a nestedIncludeorCustomTag/BlockCustomTagduring its walk, so wrapper deps include the wildcard and those nodes are always re-rendered. (crates/djust_templates/src/parser.rs) _force_full_htmlnow callsset_changed_keysso Rust partial renderer re-renders (#783) — When_force_full_htmlwas set,_sync_state_to_rust()clearedprev_refsto force all context to Rust, but theset_changed_keyscall was gated byif prev_refswhich evaluated to False after clearing. Rust's partial renderer saw nochanged_keys, fell back to full render with emptychanged_indices, and the text-region fast-path compared identical old/new HTML → zero patches. Fix:set_changed_keysis now called when_force_full_htmlis set regardless ofprev_refs.
Docs
- ROADMAP correction:
temporary_assignsis already implemented — The v0.5.0 ROADMAP entry claimingtemporary_assignswas "completely absent from djust today" was inaccurate. The feature has shipped in earlier releases (LiveView._initialize_temporary_assigns/_reset_temporary_assigns, wired into the render cycle and excluded from change tracking). This PR adds a dedicated regression test (tests/unit/test_temporary_assigns.py) — prior coverage was indirect — and strikes through the ROADMAP entry.
Tests
- Regression coverage for
temporary_assigns—tests/unit/test_temporary_assigns.pycovers reset-after-render semantics, default-value cloning per type (list / dict / set / scalar), idempotent initialization, pre-existing-attribute preservation, instance-level override, and the empty-mapping no-op path. - Unit tests for
assign_async/AsyncResult—tests/unit/test_assign_async.py(18 tests) covers state-flag invariants, frozen dataclass immutability, pending-is-set-immediately, success & failure propagation, multi-concurrent scheduling, cancellation interop withcancel_async, sync and async loaders, args/kwargs forwarding, and the generation-counter / stale-loader regression cases added in #793. - Unit tests for
{% dj_suspense %}—tests/unit/test_suspense.py(12 tests) covers ok → body, loading → fallback, failed → error-div, HTML-escaped error messages, no-await=passthrough, unknown / non-AsyncResultrefs defaulting to loading, default spinner, Django template fallback, template-error graceful degradation, nesting, and whitespace-tolerant comma-separated lists. - Regression suite for
|safeHTML blob diff (#783) —tests/test_rust_vdom_safe_diff_783.pyexercises the WizardMixin-style pattern wherefield_htmlis derived inget_context_data()from an instance attribute. Covers dict reassignment, in-place nested mutation, the_force_full_htmlcodepath, an{% if %}branch swap, a{% extends %}/{% block %}inheritance chain, and the exact downstream-consumer-style{% extends %} + {% if %} + {% include %}structure that originally exhibited the bug. All variants assert non-empty VDOM patches on state change. - Dep-extractor hardening (#783 follow-up, P0) — Three-part hardening against silent dep-drop regressions in
crates/djust_templates/src/parser.rs::extract_from_nodes:- Rust unit tests for
extract_per_node_deps— table-driven assertions on representative AST shapes (simple Variable, If-wrapping-Include, For with tuple unpacking, With + body, InlineIf condition, nested For, Block recursion, plain Text). Explicit"*"wildcard membership checks for nestedInclude/CustomTagshapes. - Node variant exhaustiveness check —
sample_for_coverageexhaustive match onNode::*+sample_nodes()constructor +NO_VARS_VARIANTSallow-list. Any newNodevariant fails to compile until the match is updated, and at runtime every non-allow-listed variant must produce a non-empty dep set (real vars or"*"wildcard). Makes silent dep-drops on future additions impossible. - Partial-render correctness harness (Python) —
TestPartialRenderCorrectnessintests/test_rust_vdom_safe_diff_783.py. Byte-equality oracle: for each of 6 wrapper shapes (no-wrapper,{% if %},{% for %},{% with %}, full #783 extends/if/include/safe chain, InlineIf-in-for), renders a mutation via the normal partial-render path then re-renders the same mutation with the Rust fragment cache cleared (clear_fragment_cache()) as a control. Any dep-miss that causes partial render to reuse a stale cached fragment diverges from the control and fails.
- Rust unit tests for
- New PyO3 method
DjustLiveView.clear_fragment_cache(test-only) (crates/djust_live/src/lib.rs) — clearsnode_html_cache,last_html,fragment_text_map,text_node_indexwhile preservinglast_vdomso the diff baseline is unchanged. Exclusively supports the partial-render correctness harness above; not intended for application use.