Added
WizardMixin.wizard_rendered_fieldsopt-in skipsfield_htmlrendering for fields not in the list (closes #1097) —WizardMixin.get_context_data()unconditionally pre-renderedfield_htmlfor every field on the current step's form, regardless of whether the template referenced that field. Wizards with conditional fields (e.g. owner-info hidden behindis_vehicle_owner == "no") paid the rendering cost on every event for fields nobody ever sees. Reported impact on the downstream-consumer VPD wizard: 115ms template render (threshold: 50ms), 47 VDOM patches per autofill — most for invisible inputs.New API (default behavior unchanged —
Nonerenders all):- Class-level:
wizard_rendered_fields = ["first_name", "vin", ...]on the wizard view limitsfield_htmlto that subset across every step. - Per-step override: a step dict can include
{"name": "...", "form_class": ..., "rendered_fields": [...]}to scope the filter to that step. Wins over the class-level default.
form_data,form_required, andform_choicesare NOT filtered — all fields remain part of validation/state. Only the (expensive) HTML rendering is opt-in skipped. Excluded field names produce nofield_html[fname]entry; templates that reference them via{{ field_html.unused|safe }}render empty (the dict-key absence is intentional and visible).Files:
python/djust/wizard.py(class attribute, per-step lookup + filter inget_context_data());python/tests/test_wizard_rendered_fields.pywith 8 cases inDefaultRendersAllFieldsTest,ClassAttributeFiltersTest,PerStepOverrideTest.Future direction: a smarter automatic template-scan (similar to the JIT serializer's used-field detection) could drive this without explicit developer wiring. This PR ships the explicit escape hatch first.
- Class-level:
WizardMixin.wizard_input_eventclass attribute +dom_eventkwarg onas_live_field()— configurable DOM event for live-field validation binding (closes #1095) —WizardMixin.as_live_field()previously emitteddj-change="<handler>"unconditionally on text/textarea/select/checkbox/ radio inputs.dj-changefires only on blur, so a user who edits a pre-filled field and clicks Next without tabbing away has their edit silently discarded. Wizards with autofill or pre-filled-from-database fields hit this routinely.New API: a class-level default and a per-call override.
Class default::
class MyWizard(WizardMixin, LiveView): wizard_input_event = "dj-input" # default: "dj-change"Per-call override::
view.as_live_field("email", dom_event="dj-input")dj-inputfires on every keystroke (300ms client-side debounce already in09-event-binding.js), so edits land regardless of whether the user blurs first. Per-call kwarg wins over the class attribute.Default behavior unchanged (
"dj-change"), so this is a strictly additive opt-in — existing wizards see no behavior change. Replaces the regex post-process workaround that downstream consumers (e.g. downstream-consumer PR #185) had to maintain.Files:
python/djust/wizard.py(class attribute,as_live_fieldforwardsdom_eventthroughkwargs.setdefault),python/djust/ frameworks.py(5 sites —_render_inputtext/textarea/select,_render_checkbox,_render_radio— readkwargs.get("dom_event", "dj-change")instead of hardcoding"dj-change").14 regression cases in
python/tests/test_wizard_input_event.pycover: default class attribute is"dj-change"; default rendering on text/textarea/select/checkbox/radio emitsdj-change; per-calldom_event="dj-input"swaps todj-inputand removesdj-change;wizard_input_event = "dj-input"flows throughas_live_field(); per-call kwarg overrides class attribute;dom_event=Nonecoalesces to the class attr instead of producingattrs[None].self.defer(callback, *args, **kwargs)— Phoenix-style post-render callback scheduling — new method onAsyncWorkMixin(and therefore on everyLiveView) that schedules a callback to run once, after the current render+patch cycle completes. Phoenixsend(self(), :foo)/ ReactuseEffect(post-render) parity. Fires synchronously in the same WebSocket message cycle (after_send_updatereturns) — so deferred callbacks observe the post-patch state. Use cases: telemetry emission after the user sees the change, post-render cleanup of temporary state, scheduling follow-up side effects without re-rendering.Differs from
start_async:deferdoes NOT trigger a re-render after the callback returns (the caller would usestart_asyncfor that), and runs synchronously in the same WS frame rather than spawning a background thread. Append-only queue: everydefer()call adds to a per-view list that is drained and cleared byLiveViewConsumer._flush_deferred()after every_send_update()call (10 sites inpython/djust/websocket.py, mirroring the existing_flush_push_events/_flush_flash/_flush_page_metadata/_flush_pending_layoutpost-render-flush pattern).Async callbacks (
async defor coroutine-returning) are awaited inline. Exception isolation: a failing deferred callback is logged at WARN with full traceback and execution continues to the next callback in the queue — a deferred callback's failure must not break the WebSocket connection or the user's interactive flow. 19 regression cases inpython/djust/tests/test_defer.pycover queue mechanics (append/drain/clear), arg/kwarg passing, ordering, sync+async mix, exception isolation, edge cases (noview_instance, view withoutAsyncWorkMixin), drain-reentry contract (a callback that callsdefer(other)enqueuesotherfor the next drain — Phoenix-style, prevents unbounded loops), and SSE transport integration (mirror flush via_flush_deferred_to_sse()inpython/djust/sse.py).Example::
class CounterView(LiveView): @event_handler def increment(self, **kwargs): self.count += 1 self.defer(self._record_metric, action="increment") def _record_metric(self, action: str): # Fires AFTER the patch reaches the client. metrics.increment(f"liveview.{action}", count=self.count)Phoenix LiveView Parity Tracker entry
self.defer()(post-render) marked shipped inROADMAP.md.
Changed
VDOM
applyPatchessignature is nowasync(returnsPromise<boolean>) — foundational refactor preparing for View Transitions API integration (ADR-013). PreviouslyapplyPatches(patches, rootEl)returnedbooleansynchronously; nowasync function applyPatches(patches, rootEl) -> Promise<boolean>. The patch-loop body itself is unchanged — this is a signature-only migration. Direct caller migration covers six call sites across the client modules:02-response-handler.js,03-websocket.js,03b-sse.js,11-event-handler.js,45-child-view.js. EachawaitsapplyPatchesand propagates async upward —handleServerResponseis nowasync,LiveViewWebSocket.handleMessageandLiveViewSSE.handleMessageare nowasync, and theEventSourceonmessagearrow callbacks (which cannot beasyncin their declared form) wrap theirhandleMessageinvocations in.catch()to preserve unhandled-rejection visibility._applyScopedPatches,handleChildUpdate, andhandleStickyUpdatein45-child-view.jsare alsoasync.Why this signature change matters:
document.startViewTransition()'s callback runs in a microtask after the browser captures the pre-patch frame, NOT synchronously, so any wrapping that schedules patches viastartViewTransitionrequires the patch function to be awaitable. PR-A (this entry) is the foundation; PR-B will add the View Transitions wrap on top without further signature changes.No external API change for view authors — VDOM internals only. Newly exposed public surface:
window.djust.applyPatchesis now explicitly assigned viaglobalThis.djust.applyPatches = applyPatchesat the end of12-vdom-patch.js. (Previously the function was reachable in test environments only byeval-host-scope hoisting, which async declarations don't honor under JSDOM.) Hook code that monkey-patchesapplyPatchesshould now address the namespace explicitly and treat the return value as aPromise<boolean>.Test surface migrated: 8 JS test files updated to
awaitapplyPatches/handleMessage/handleServerResponsecalls and switch todom.window.djust.applyPatches. 1396 JS tests pass; 4230 Python tests pass; behavior parity with the previous sync signature confirmed by the existing patch test suite (vdom_patch_errors.test.js,vdom_recovery.test.js,tab_switch_real_repro.test.js,event_sequencing.test.js,batch_insert_before_remove.test.js,vdom-autofocus.test.js,sse.test.js).
Fixed
djust.T012false positive on{% include %}partial templates (closes #1096) —T012(template usesdj-*event directives but missingdj-view) fired unconditionally for any template containingdj-click,dj-input, etc., even when the file was an intentional fragment included from a parent LiveView root. Wizards with 15+ step partials produced a noisy 15-warning wall inmanage.py check.Two opt-out paths now silence T012 for legitimate fragments:
- Per-template marker: add
{# djust:partial #}(case-insensitive, whitespace flexible) anywhere in the template. The marker is the right choice when most fragments in a project don't need the check but a few full-page templates do. - Global suppression:
DJUST_CONFIG = {"suppress_checks": ["T012"]}insettings.py. Right when the project never uses T012's intended diagnostic (e.g. component-only architectures).
T012's hint now mentions both options. Component templates (
dj-componentpresent) continue to bypass T012 as before — pre-existing behavior unchanged.Files:
python/djust/checks.py(new_DJ_PARTIAL_MARKER_RE, T012 guard reads partial marker AND_is_check_suppressed("djust.T012")— previously the global suppression infrastructure existed but T012 wasn't wired in). New cases added toTestT012EventDirectivesWithoutViewinpython/tests/test_checks.pycover: partial marker silences T012; case-insensitive matching; global suppression via short ID ("T012") and qualified ID ("djust.T012"); hint text mentions both opt-out paths.- Per-template marker: add
scripts/check-changelog-test-counts.pyregex missedasync def test_*— the test-counter pre-push hook'sPY_TEST_FN_REmatched onlydef test_*, silently undercounting pytest-asyncio test files (any module-levelasync def test_*was invisible). Updated the pattern to^[ \t]*(?:async\s+)?def\s+test_\w+\s*\(so async tests are counted alongside sync tests. Surfaced viatests/test_defer.py(7 sync class-method tests + 7 module-level async tests = 14 total; pre-fix the hook reported 7 and the CHANGELOG claim of "14 regression cases" tripped a false drift warning). Mechanical fix; no behavior change for files that don't useasync def test_*.