djust 0.8.6rc1

Pre-releaseReleased

Changed

  • Process canonicalizations from the v0.8.5 → v0.8.6 retro arc folded into CLAUDE.md (closes #1100, #1101, #1103, #1104, #1106, #1108, #1109) — Eight Stage 11 / retro-tracker learnings from the View Transitions PR-A → PR-B arc and the downstream-consumer gap-fix arc are now canonicalized as a single "Process canonicalizations" section in CLAUDE.md. Each rule names the source PR so the audit trail is preserved.

    Topics covered: completeness-grep after async-migration regex passes (#1100); ADR scope-estimation counts test-file callers (#1101); is None coalesce vs kwargs.setdefault for mixin kwarg-forwarding (#1103); mechanical-replacement PRs need N tests for N sites (#1104); CHANGELOG test-count phrasing for additions to existing files (#1106); Iterable[T] over list[T] for membership-check parameters (#1108); dynamic subclass via type(name, bases, dict) over class-attr mutation in test fixtures (#1109); microtask-faithful test stubs for startViewTransition / MutationObserver / IntersectionObserver (PR #1113 retro); batch-PR issue × file × test mapping table convention (PR #1115 retro).

    Docs-only change. No code or test surface modified.

Added

  • djust.C013 system check — stale collectstatic copy of client.min.js (closes #1088) — anyone with STATIC_ROOT configured (typical production deployment behind WhiteNoise / nginx / a CDN) can ship a stale client.min.js after a djust wheel upgrade if they forget collectstatic --clear. The server runs new code; the browser loads old client.js → wire-protocol skew → mysterious VDOM patch failures. #1081 was reopened twice before the reporter root-caused this structurally-recurring trap.

    C013 compares the SHA-256 of STATIC_ROOT/djust/client.min.js against the wheel-bundled copy at python/djust/static/djust/client.min.js. When they diverge, emits a Django system warning at startup with the exact fix command. No-op when STATIC_ROOT is unset, when the collected file is absent (pre-collectstatic), or when content matches. Honors DJUST_CONFIG = {"suppress_checks": ["C013"]} for users who serve client.min.js from a CDN or custom build.

    Files: python/djust/checks.py (new _check_stale_collected_client, wired into check_configuration); 5 cases in TestC013StaleCollectstatic in python/tests/test_checks.py cover no-STATIC_ROOT skip, no-collected-file skip, matching-content quiet, diverged-content warning, suppress-via-DJUST_CONFIG silence.

Fixed

  • |date and |time filters now debug-log on parse failure (closes #1090) — both filters previously fell through silently to the original value when chrono failed to parse the input string. The #1081 4-round-reopen investigation would have collapsed to a 5-minute diagnosis if a single line had been logged at parse-failure time. Now the failure is surfaced via tracing::debug! against target djust.templates.filters with the offending value, format string, and chrono error message.

    Enable via Python LOGGING['loggers']['djust.templates.filters'] = {'level': 'DEBUG'} or set RUST_LOG=djust.templates.filters=debug for the Rust-side tracing consumer. Behavior unchanged when the log target is disabled — just no longer a silent void.

    Files: crates/djust_templates/Cargo.toml (added tracing workspace dep), crates/djust_templates/src/filters.rs (|date arm at line ~248, |time arm at line ~284 — replaced Err(_) => Ok(value.clone()) with Err(e) => { tracing::debug!(...); Ok(value.clone()) }).

  • _flush_deferred_to_sse legacy-view guard now has a regression test (closes #1093) — Stage 13 review of PR #1091 flagged that the WS-side hasattr guard had a parallel test (test_flush_deferred_handles_view_without_drain_method) but the SSE-side did not. New test_sse_flush_deferred_handles_view_without_drain_method in python/djust/tests/test_defer.py mirrors the WS shape — a legacy view class without _drain_deferred must short-circuit cleanly without AttributeError.

  • Release wheel matrix expanded to cp313 + cp314 (closes #1089).github/workflows/release.yml previously built only cp310/cp311/cp312 wheels. Users on Python 3.13 or 3.14 fell back to source-compiling the sdist at pip install time, producing untested binaries whose runtime behavior could diverge from CI-tested cp312 (this was the root cause of #1081's first reopen — reporter on 3.14 hit a source- compiled _rust.cpython-314-darwin.so). Matrix now ships tested wheels for cp310–cp314 across Linux x86_64, macOS Intel + ARM, and Windows x86_64 (Windows still excludes 3.10 per the existing policy).

  • View Transitions API integration in applyPatches (PR-B / ADR-013) — Opt-in via <body dj-view-transitions>. When the browser supports document.startViewTransition() AND the body attribute is present AND the user has not requested prefers-reduced-motion: reduce, every server-driven VDOM patch is wrapped in a View Transition: the browser captures a pre-state frame, runs our patch loop, captures the post-state, and animates between them.

    Default cross-fade for free, with one body-level attribute. Shared- element morphs via view-transition-name CSS — animate matching named elements between two completely different DOM trees (the "card flies into hero on detail page" pattern). Custom animation timing/easing via ::view-transition-old(name) / ::view-transition-new(name) pseudo-elements — designer-driven, no JS.

    Browser support gate: Chrome 111+, Edge 111+, Safari 18+. Firefox graceful-degrades — patches still apply, no animation. ~85% of current djust users get the polish; the remaining ~15% see no regression. Re-evaluated on every patch so dynamic mid-session opt-in via document.body.setAttribute('dj-view-transitions', '') works.

    Failure path: when the wrap callback throws, the wrapper logs at ERROR, calls transition.skipTransition() to abandon the animation, and returns false so the existing full-re-render fallback at 02-response-handler.js:109 fires. The async signature shipped in v0.8.5rc1 (PR-A) is what makes the callback's microtask semantics observable — the previous attempt (PR #1092) used a sync callback and silently lost the boolean return.

    Why this matters: View Transitions enables wizard step morphs, modal open/close animations, navigation-primitive page transitions (free polish for the dj-prefetch work shipped in v0.7.0), list reorders, and tab-switch cross-fades — without per-component animation code or runtime JS animation libraries.

    Files: python/djust/static/djust/src/12-vdom-patch.js adds _shouldUseViewTransition() gate and refactors applyPatches into a thin wrap-or-direct dispatcher; the existing patch-loop body becomes _applyPatchesInner (sync — no behavior change inside). Cleanup: 03-websocket.js (2 sites) and 03b-sse.js (1 site) drop the now-redundant outer .catch() on handleMessage calls — the queue wrapper from #1098 already has an internal .catch(), so the outer was dead code (Stage 11 nit from PR #1112).

    New test file tests/js/view-transitions.test.js covers all four _shouldUseViewTransition branches (API present, opt-in absent, opt-in present, reduced-motion), success/empty/wrap-throws paths, microtask-deferral correctness (DOM is unchanged before await), dynamic mid-session opt-in toggle, and direct-path parity. The vitest stub invokes the callback in a microtask via await Promise.resolve() to mirror real-browser semantics — NOT synchronously like the failed PR #1092 stub.

    ROADMAP Phoenix LiveView Parity Tracker View Transitions API → shipped. Quick Win #23 closed.

Added

  • Async-tolerant dj-hook lifecycle dispatch (v0.8.6 enhancement cashing in PR-A async refactor)dj-hook lifecycle methods (mounted, updated, beforeUpdate, destroyed, disconnected, reconnected, handleEvent) may now be async. The dispatcher detects Promise return and chains .catch to log rejections via console.error — no Unhandled Promise Rejection in the browser console.

    window.djust.hooks.UserAvatar = {
        async mounted() {
            const res = await fetch(`/api/profile/${this.el.dataset.userId}`);
            const profile = await res.json();
            this.el.querySelector('img').src = profile.avatar_url;
        },
    };
    

    Fire-and-forget contract: the dispatcher does NOT await user hooks. Lifecycle callbacks fire-and-forget so user I/O can't block the render loop. Sync hooks behave exactly as before — strictly additive, no API change for existing hook code.

    Implementation: new _safeCallHook(fn, label, ...args) helper in python/djust/static/djust/src/19-hooks.js wraps the existing try/catch sites for each lifecycle path. 9 sync sites refactored to use the helper (mounted×2, beforeUpdate, updated, destroyed×2, disconnected, reconnected, handleEvent). New file tests/js/async_hooks.test.js with 5 cases cover sync-unchanged behavior + async-Promise-rejection-logging + fire-and-forget timing contract.

  • docs/website/guides/view-transitions.md — comprehensive guide for the View Transitions API integration shipped in v0.8.6 PR #1113 — covers the <body dj-view-transitions> opt-in, browser support matrix (Chrome 111+, Edge 111+, Safari 18+, Firefox graceful degrade), prefers-reduced-motion accessibility bypass, shared-element transitions via view-transition-name, custom animation timing via ::view-transition-old(name) / ::view-transition-new(name) pseudo-elements, await window.djust.applyPatches(...) as public API for third-party JS, and a critical "JSDOM stub microtask correctness" section (mirroring the regression class that bit PR #1092). Linked from _config.yaml and index.md per the docs-nav convention.

  • {% data_table %} link column type (closes #1110) — column dicts now accept a link key naming another row dict key that holds the href, and an optional link_class for the <a> element's CSS class:

    table_columns = [
        {"key": "claim_number", "label": "Claim #", "link": "claim_url",
         "link_class": "claim-link"},
    ]
    # row dicts include both keys:
    {"claim_number": "2026PI000001", "claim_url": "/claims/1/", ...}
    

    Renders as:

    <td><a href="/claims/1/" class="claim-link">2026PI000001</a></td>
    

    Falls through to plain text when col.link is unset — strict backwards-compat with pre-#1110 column dicts. Replaces the _inject_link_column regex post-process workaround downstream consumers had to maintain (e.g. downstream-consumer).

  • {% data_table %} row-level navigation: row_click_event + row_url (closes #1111) — the entire <tr> becomes clickable for navigation. Two API shapes:

    Option B (preferred — LiveView-idiomatic): row_click_event fires a djust event with data-value=row[row_click_value_key]. Default value key is "id"; override per-table for slug-based routing:

    table_row_click_event = "open_claim"
    table_row_click_value_key = "uuid"
    
    @event_handler()
    def open_claim(self, value: str = "", **kwargs):
        self.redirect(reverse("claims:detail", kwargs={"claim_id": value}))
    

    Option A (static URL fallback): row_url names a row dict key containing the href; the <tr> gets data-href + an onclick that reads this.dataset.href and navigates:

    table_row_url = "claim_url"
    

    Both options also wire style="cursor:pointer" on each <tr> for the affordance. row_click_event takes precedence when both are set. Mirrored in DataTableMixin via table_row_click_event, table_row_click_value_key, and table_row_url class attributes, threaded through get_table_context() and _PRE_MOUNT_TABLE_CONTEXT.

    Security note for Option A (row_url): the URL flows into JS via onclick="window.location=this.dataset.href". Only assign developer-controlled URLs (typically computed from reverse()); user-controlled strings could enable javascript: URI execution. CSP note: Option A requires 'unsafe-inline' in script-src; prefer Option B (LiveView event) when CSP is strict. Option B is CSP-clean — the click is dispatched via the existing djust event pipeline, no inline JS executed.

    14 regression cases in python/tests/test_data_table_link_row_nav.py cover: link-column emits <a>; link_class flows through; no-link pre-#1110 compat; row_click_event adds dj-click to every <tr>; row_click_value_key overrides default id; absent row_click_event → no <tr> dj-click (compat); row_url adds data-href + JS; row_click_event precedence over row_url; mixin class-attr defaults; per-view override; pre-mount default + post-mount context

    • template-tag function include all 3 new keys.

Fixed

  • DataTableMixin LiveView compatibility — pre-mount guard + @event_handler() decoration on all on_table_* methods (closes #1114) — using DataTableMixin in a LiveView (rather than a Component) caused a blank/empty table on every page load even when refresh_table_server() correctly populated self.table_rows in mount(). Three compounding root causes:

    1. BUG-06 pre-mount lifecycle: djust's WebSocket consumer calls get_context_data() (which often calls get_table_context()) BEFORE mount() runs, to build the initial Rust VDOM snapshot. init_table_state() hadn't run yet, so self.table_rows didn't exist and get_table_context() raised AttributeError. djust caught it silently → empty initial VDOM → all subsequent VDOM patches diff against empty content → wrong renders.
    2. Missing @event_handler() decoration: on_table_sort, on_table_search, and 19 other handlers were plain methods. djust's default event_security="strict" rejected them — every consumer had to write wrapper boilerplate.
    3. Documentation gap: the API boundary between Component and LiveView use cases wasn't called out anywhere in the mixin's docstring.

    Fix: get_table_context() now guards on hasattr(self, "table_rows") and returns _PRE_MOUNT_TABLE_CONTEXT (a module-level minimal default with every key the {% data_table %} template tag reads — ~80 keys covering all 5 phases). All 21 on_table_* handlers now carry @event_handler() decoration. Mixin docstring expanded with a "LiveView vs Component lifecycle" note + recommended pattern for large datasets (pass queryset directly via get_context_data(), define @event_handler() methods on the view).

    Downstream impact: downstream-consumer PR #189 attempted migration and hit this; PR #191 reverted to native handlers. With this fix, DataTableMixin is usable from LiveView subclasses without per-handler boilerplate.

    8 regression cases in python/tests/test_data_table_mixin_liveview.py cover: pre-mount call doesn't raise; pre-mount returns the default; post-mount returns real state; pre-mount key set is a superset of post-mount (catches future post-mount additions that forgot to update the default); all 21 expected handlers have _djust_decorators metadata; handler count matches expected (catches future additions that forgot decoration); docstring mentions LiveView lifecycle and @event_handler() decoration (catches doc-rot).

  • handleMessage interleaving across await boundaries (closes #1098) — PR-A (v0.8.5rc1) made LiveViewWebSocket.handleMessage and LiveViewSSE.handleMessage async without serializing the inbound frame queue. Two adjacent inbound frames could fire-and-forget _handleMessageImpl concurrently and interleave their await handleServerResponse calls — racing on shared state like _pendingEventRefs / _tickBuffer (03-websocket.js:561-568 reads .size AFTER an await, so an in-flight second message could mutate the set between check and flush). Latent today; would have been meaningfully worse when PR-B (View Transitions wrap) widened the await window inside applyPatches itself.

    Fix: per-transport _inflight Promise chain. Each handleMessage(data) invocation chains onto the prior in-flight promise. Sequential drain across rapid-fire frames; no interleaving. Errors propagate through .catch() (logged via console.error) so the chain continues even when one frame rejects — a single bad frame doesn't poison the queue.

    Existing async handleMessage body renamed to _handleMessageImpl (private). New public handleMessage(data) is a thin wrapper that enqueues onto this._inflight. Both transports (WebSocket + SSE) apply the same pattern.

    New regression file tests/js/handlemessage_serialization.test.js covers: rapid-fire ordered drain (later messages with shorter delays must NOT finish first); throwing message doesn't poison the chain; returned promise resolves only after this frame drains; both WS and SSE expose handleMessage and _handleMessageImpl separately and serialize.

    Caller-side test migration: 4 existing test files updated to await the now-queued handleMessage calls (dj-cloak, hvr, sse-transport, sw_advanced) — same kind of un-awaited-call gap that Stage 11 caught on PR #1099. 1402 JS tests pass; 2080 Python tests pass.

    PR-B (View Transitions wrap) is now unblocked.

All releases · Atom feed