djust 0.8.5rc1

Pre-releaseReleased

Added

  • WizardMixin.wizard_rendered_fields opt-in skips field_html rendering for fields not in the list (closes #1097)WizardMixin.get_context_data() unconditionally pre-rendered field_html for 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 behind is_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 — None renders all):

    • Class-level: wizard_rendered_fields = ["first_name", "vin", ...] on the wizard view limits field_html to 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, and form_choices are NOT filtered — all fields remain part of validation/state. Only the (expensive) HTML rendering is opt-in skipped. Excluded field names produce no field_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 in get_context_data()); python/tests/test_wizard_rendered_fields.py with 8 cases in DefaultRendersAllFieldsTest, 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.

  • WizardMixin.wizard_input_event class attribute + dom_event kwarg on as_live_field() — configurable DOM event for live-field validation binding (closes #1095)WizardMixin.as_live_field() previously emitted dj-change="<handler>" unconditionally on text/textarea/select/checkbox/ radio inputs. dj-change fires 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-input fires on every keystroke (300ms client-side debounce already in 09-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_field forwards dom_event through kwargs.setdefault), python/djust/ frameworks.py (5 sites — _render_input text/textarea/select, _render_checkbox, _render_radio — read kwargs.get("dom_event", "dj-change") instead of hardcoding "dj-change").

    14 regression cases in python/tests/test_wizard_input_event.py cover: default class attribute is "dj-change"; default rendering on text/textarea/select/checkbox/radio emits dj-change; per-call dom_event="dj-input" swaps to dj-input and removes dj-change; wizard_input_event = "dj-input" flows through as_live_field(); per-call kwarg overrides class attribute; dom_event=None coalesces to the class attr instead of producing attrs[None].

  • self.defer(callback, *args, **kwargs) — Phoenix-style post-render callback scheduling — new method on AsyncWorkMixin (and therefore on every LiveView) that schedules a callback to run once, after the current render+patch cycle completes. Phoenix send(self(), :foo) / React useEffect (post-render) parity. Fires synchronously in the same WebSocket message cycle (after _send_update returns) — 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: defer does NOT trigger a re-render after the callback returns (the caller would use start_async for that), and runs synchronously in the same WS frame rather than spawning a background thread. Append-only queue: every defer() call adds to a per-view list that is drained and cleared by LiveViewConsumer._flush_deferred() after every _send_update() call (10 sites in python/djust/websocket.py, mirroring the existing _flush_push_events / _flush_flash / _flush_page_metadata / _flush_pending_layout post-render-flush pattern).

    Async callbacks (async def or 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 in python/djust/tests/test_defer.py cover queue mechanics (append/drain/clear), arg/kwarg passing, ordering, sync+async mix, exception isolation, edge cases (no view_instance, view without AsyncWorkMixin), drain-reentry contract (a callback that calls defer(other) enqueues other for the next drain — Phoenix-style, prevents unbounded loops), and SSE transport integration (mirror flush via _flush_deferred_to_sse() in python/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 in ROADMAP.md.

Changed

  • VDOM applyPatches signature is now async (returns Promise<boolean>) — foundational refactor preparing for View Transitions API integration (ADR-013). Previously applyPatches(patches, rootEl) returned boolean synchronously; now async 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. Each awaits applyPatches and propagates async upward — handleServerResponse is now async, LiveViewWebSocket.handleMessage and LiveViewSSE.handleMessage are now async, and the EventSourceonmessage arrow callbacks (which cannot be async in their declared form) wrap their handleMessage invocations in .catch() to preserve unhandled-rejection visibility. _applyScopedPatches, handleChildUpdate, and handleStickyUpdate in 45-child-view.js are also async.

    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 via startViewTransition requires 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.applyPatches is now explicitly assigned via globalThis.djust.applyPatches = applyPatches at the end of 12-vdom-patch.js. (Previously the function was reachable in test environments only by eval-host-scope hoisting, which async declarations don't honor under JSDOM.) Hook code that monkey-patches applyPatches should now address the namespace explicitly and treat the return value as a Promise<boolean>.

    Test surface migrated: 8 JS test files updated to awaitapplyPatches / handleMessage / handleServerResponse calls and switch to dom.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.T012 false positive on {% include %} partial templates (closes #1096)T012 (template uses dj-* event directives but missing dj-view) fired unconditionally for any template containing dj-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 in manage.py check.

    Two opt-out paths now silence T012 for legitimate fragments:

    1. 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.
    2. Global suppression: DJUST_CONFIG = {"suppress_checks": ["T012"]} in settings.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-component present) 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 to TestT012EventDirectivesWithoutView in python/tests/test_checks.py cover: 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.

  • scripts/check-changelog-test-counts.py regex missed async def test_* — the test-counter pre-push hook's PY_TEST_FN_RE matched only def test_*, silently undercounting pytest-asyncio test files (any module-level async 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 via tests/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 use async def test_*.

All releases · Atom feed