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 Nonecoalesce vskwargs.setdefaultfor 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]overlist[T]for membership-check parameters (#1108); dynamic subclass viatype(name, bases, dict)over class-attr mutation in test fixtures (#1109); microtask-faithful test stubs forstartViewTransition/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.C013system check — stale collectstatic copy ofclient.min.js(closes #1088) — anyone withSTATIC_ROOTconfigured (typical production deployment behind WhiteNoise / nginx / a CDN) can ship a staleclient.min.jsafter a djust wheel upgrade if they forgetcollectstatic --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.jsagainst the wheel-bundled copy atpython/djust/static/djust/client.min.js. When they diverge, emits a Django system warning at startup with the exact fix command. No-op whenSTATIC_ROOTis unset, when the collected file is absent (pre-collectstatic), or when content matches. HonorsDJUST_CONFIG = {"suppress_checks": ["C013"]}for users who serveclient.min.jsfrom a CDN or custom build.Files:
python/djust/checks.py(new_check_stale_collected_client, wired intocheck_configuration); 5 cases inTestC013StaleCollectstaticinpython/tests/test_checks.pycover no-STATIC_ROOT skip, no-collected-file skip, matching-content quiet, diverged-content warning, suppress-via-DJUST_CONFIG silence.
Fixed
|dateand|timefilters 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 viatracing::debug!against targetdjust.templates.filterswith the offending value, format string, and chrono error message.Enable via Python
LOGGING['loggers']['djust.templates.filters'] = {'level': 'DEBUG'}or setRUST_LOG=djust.templates.filters=debugfor the Rust-sidetracingconsumer. Behavior unchanged when the log target is disabled — just no longer a silent void.Files:
crates/djust_templates/Cargo.toml(addedtracingworkspace dep),crates/djust_templates/src/filters.rs(|datearm at line ~248,|timearm at line ~284 — replacedErr(_) => Ok(value.clone())withErr(e) => { tracing::debug!(...); Ok(value.clone()) })._flush_deferred_to_sselegacy-view guard now has a regression test (closes #1093) — Stage 13 review of PR #1091 flagged that the WS-sidehasattrguard had a parallel test (test_flush_deferred_handles_view_without_drain_method) but the SSE-side did not. Newtest_sse_flush_deferred_handles_view_without_drain_methodinpython/djust/tests/test_defer.pymirrors the WS shape — a legacy view class without_drain_deferredmust short-circuit cleanly withoutAttributeError.Release wheel matrix expanded to cp313 + cp314 (closes #1089) —
.github/workflows/release.ymlpreviously built only cp310/cp311/cp312 wheels. Users on Python 3.13 or 3.14 fell back to source-compiling the sdist atpip installtime, 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 supportsdocument.startViewTransition()AND the body attribute is present AND the user has not requestedprefers-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-nameCSS — 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 at02-response-handler.js:109fires. 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-prefetchwork 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.jsadds_shouldUseViewTransition()gate and refactorsapplyPatchesinto a thin wrap-or-direct dispatcher; the existing patch-loop body becomes_applyPatchesInner(sync — no behavior change inside). Cleanup:03-websocket.js(2 sites) and03b-sse.js(1 site) drop the now-redundant outer.catch()onhandleMessagecalls — 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.jscovers all four_shouldUseViewTransitionbranches (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 viaawait 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-hooklifecycle dispatch (v0.8.6 enhancement cashing in PR-A async refactor) —dj-hooklifecycle methods (mounted,updated,beforeUpdate,destroyed,disconnected,reconnected,handleEvent) may now beasync. The dispatcher detects Promise return and chains.catchto log rejections viaconsole.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 inpython/djust/static/djust/src/19-hooks.jswraps 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 filetests/js/async_hooks.test.jswith 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-motionaccessibility bypass, shared-element transitions viaview-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.yamlandindex.mdper the docs-nav convention.{% data_table %}link column type (closes #1110) — column dicts now accept alinkkey naming another row dict key that holds the href, and an optionallink_classfor 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.linkis unset — strict backwards-compat with pre-#1110 column dicts. Replaces the_inject_link_columnregex 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_eventfires a djust event withdata-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_urlnames a row dict key containing the href; the<tr>getsdata-href+ anonclickthat readsthis.dataset.hrefand navigates:table_row_url = "claim_url"Both options also wire
style="cursor:pointer"on each<tr>for the affordance.row_click_eventtakes precedence when both are set. Mirrored inDataTableMixinviatable_row_click_event,table_row_click_value_key, andtable_row_urlclass attributes, threaded throughget_table_context()and_PRE_MOUNT_TABLE_CONTEXT.Security note for Option A (
row_url): the URL flows into JS viaonclick="window.location=this.dataset.href". Only assign developer-controlled URLs (typically computed fromreverse()); user-controlled strings could enablejavascript:URI execution. CSP note: Option A requires'unsafe-inline'inscript-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.pycover: link-column emits<a>; link_class flows through; no-link pre-#1110 compat;row_click_eventaddsdj-clickto every<tr>;row_click_value_keyoverrides defaultid; absentrow_click_event→ no<tr>dj-click(compat);row_urladdsdata-href+ JS;row_click_eventprecedence overrow_url; mixin class-attr defaults; per-view override; pre-mount default + post-mount context- template-tag function include all 3 new keys.
Fixed
DataTableMixinLiveView compatibility — pre-mount guard +@event_handler()decoration on allon_table_*methods (closes #1114) — usingDataTableMixinin aLiveView(rather than aComponent) caused a blank/empty table on every page load even whenrefresh_table_server()correctly populatedself.table_rowsinmount(). Three compounding root causes:- BUG-06 pre-mount lifecycle: djust's WebSocket consumer calls
get_context_data()(which often callsget_table_context()) BEFOREmount()runs, to build the initial Rust VDOM snapshot.init_table_state()hadn't run yet, soself.table_rowsdidn't exist andget_table_context()raisedAttributeError. djust caught it silently → empty initial VDOM → all subsequent VDOM patches diff against empty content → wrong renders. - Missing
@event_handler()decoration:on_table_sort,on_table_search, and 19 other handlers were plain methods. djust's defaultevent_security="strict"rejected them — every consumer had to write wrapper boilerplate. - 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 onhasattr(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 21on_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 viaget_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,
DataTableMixinis usable fromLiveViewsubclasses without per-handler boilerplate.8 regression cases in
python/tests/test_data_table_mixin_liveview.pycover: 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_decoratorsmetadata; handler count matches expected (catches future additions that forgot decoration); docstring mentions LiveView lifecycle and@event_handler()decoration (catches doc-rot).- BUG-06 pre-mount lifecycle: djust's WebSocket consumer calls
handleMessageinterleaving acrossawaitboundaries (closes #1098) — PR-A (v0.8.5rc1) madeLiveViewWebSocket.handleMessageandLiveViewSSE.handleMessageasync without serializing the inbound frame queue. Two adjacent inbound frames could fire-and-forget_handleMessageImplconcurrently and interleave theirawait handleServerResponsecalls — racing on shared state like_pendingEventRefs/_tickBuffer(03-websocket.js:561-568reads.sizeAFTER anawait, 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 insideapplyPatchesitself.Fix: per-transport
_inflightPromise chain. EachhandleMessage(data)invocation chains onto the prior in-flight promise. Sequential drain across rapid-fire frames; no interleaving. Errors propagate through.catch()(logged viaconsole.error) so the chain continues even when one frame rejects — a single bad frame doesn't poison the queue.Existing async
handleMessagebody renamed to_handleMessageImpl(private). New publichandleMessage(data)is a thin wrapper that enqueues ontothis._inflight. Both transports (WebSocket + SSE) apply the same pattern.New regression file
tests/js/handlemessage_serialization.test.jscovers: 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 exposehandleMessageand_handleMessageImplseparately and serialize.Caller-side test migration: 4 existing test files updated to
awaitthe now-queuedhandleMessagecalls (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.