This is a pre-release. djust 0.9.0 has shipped since: read the djust 0.9.0 release notes.
Added
Forward-replay through branched timeline (closes #1042, v0.9.0 P3) — Redux DevTools "swap action" parity. Time-travel previously only scrubbed BACK through linear history;
replay_event(view, snapshot, override_params=None, record_replay=True)now replays a recorded event from itsstate_beforebaseline either deterministically (originalparams) or with caller-suppliedoverride_paramsto fork a branched timeline.Builds on #1041's per-component capture: replay restores via
restore_snapshot(view, snap, "before")which dispatches toview._components[id]instances. So a handler that readsself._components[id].valueduring replay sees the CAPTURED value, not the live one. The testtest_replay_restores_component_state_before_invokinglocks this in.Branches are scrubbable:
record_replay=True(default) appends the replay's new snapshot to the buffer so the branched timeline is itself navigable.record_replay=Falseruns a "dry" replay — view is mutated for preview, buffer is unchanged.Handler-missing path: returns
Noneand logs a warning (handler was renamed since the snapshot was captured). Handler-raises path: the new snapshot'serrorfield is set and the snapshot is still returned so the debug panel can show "this branch errored at step N".Files:
python/djust/time_travel.py(~85 LoC:replay_eventfunction +__all__extension). 7 new cases inTestReplayEventintests/unit/test_time_travel.pycover deterministic replay, branched timeline (override_params), buffer recording, dry replay, missing handler, handler exception, and component-state restoration during replay.v0.9.0 streaming + DevTools arc complete: PR-A foundation → PR-B
lazy=TrueAPI → PR-C parallel render → #1041 component-level capture → #1042 forward-replay.Component-level time-travel (closes #1041, v0.9.0 P3) — extends the v0.6.1 time-travel ring buffer to capture per-component public state alongside the parent LiveView's state. Multi-component pages can now scrub back through history with each component's state faithfully restored.
Snapshot format:
_capture_snapshot_stateadds a reserved__components__key holding a{component_id: {field: value}}nested dict. Components inself._components(registered by_assign_component_ids) each contribute their public state. The reserved key keeps component snapshots out of the parent's flat attr namespace and gives the time-travel debug panel a clean shape to render per-component scrubbers.Restoration:
time_travel.restore_snapshotdetects__components__in the snapshot and dispatches each{component_id: state}entry to the matching component inview._componentsviasafe_setattr. Components absent from the snapshot keep their current state — components are first-class instances, not parent-scoped attrs, so the ghost-attr cleanup model used for parent state doesn't apply.Files:
python/djust/live_view.py(~60 LoC:_capture_components_snapshothelper +_capture_snapshot_stateextension);python/djust/time_travel.py(~40 LoC:_COMPONENTS_SNAPSHOT_KEYconstant + per-component restoration phase). 7 new cases inTestComponentLevelTimeTravelintests/unit/test_time_travel.pycover capture-with-components, capture-without-components, private/callable filtering, restoration dispatch, unknown-component-id handling, absent-component preservation, and snapshot/live disconnection (mirrors the parent-state aliasing fix from PR #1023's Stage 11 review).Parallel lazy render via
asyncio.as_completed(v0.9.0 PR-C, closes #1043) — closes the v0.9.0 streaming arc. PR-B shipped sequential thunk invocation inarender_chunksPhase 5 (one thunk runs to completion before the next starts; total wall-clock time = sum of thunk durations). PR-C swaps the for-loop forasyncio.as_completedover the thunk-task set. All thunks start concurrently; chunks emerge in completion order rather than registration order. Total wall-clock time = max(thunk_durations).Client-side reconciliation is keyed by slot id (
data-targeton<template id="djl-fill-X">), so out-of-order chunk arrival is correct by construction — no client changes needed.Cancellation: when the emitter is cancelled mid-stream (client disconnect), all pending thunk tasks are cancelled via
task.cancel(). Already-completed tasks whose results were not yet iterated are GC'd. Tasks already running throughsync_to_asyncto a synchronous render function will complete (asyncio cancellation doesn't propagate into sync DB work) — the documented contract per ADR-015 §"Cancellation contract".Files:
python/djust/mixins/template.py(~50 LoC swap from for-loop toasyncio.as_completed). 3 new wall-clock-sensitive tests intests/integration/test_chunks_overlap.py:- Three thunks (100ms, 50ms, 25ms) registered in that order → chunks arrive in completion order (slot-c, slot-b, slot-a).
- Three 50ms-each thunks → wall clock under 100ms (sequential baseline 150ms).
- One thunk raises → others still emit their fills (no stall).
Closes #1043. v0.9.0 streaming arc complete: PR-A (foundation) → PR-B (
lazy=Trueuser API +as_viewdispatch) → PR-C (parallel render).{% live_render lazy=True %}capability +as_viewdispatch wiring (v0.9.0 PR-B, ADR-015) — ships the user-facing API on top of PR-A's async render foundation. Three forms:lazy=True(parent-flush trigger, default),lazy="visible"(IntersectionObserver-deferred),lazy=dict(full control —trigger,timeout_s,on_error,placeholderkeys).At template-render time the tag emits a
<dj-lazy-slot data-id="X" data-trigger="flush">placeholder synchronously and registers a thunk onparent._lazy_thunks.RequestMixin.agettransfers the stash onto theChunkEmitterafter the sync render completes. Phase-5 ofarender_chunksinvokes thunks AFTER the body-close chunk, so</body></html>lands at the wire BEFORE any lazy fill — the browser sees a fully-painted page (with placeholder spinners) while lazy children render server-side.Wire format (post-
</html>per ADR §"Wire format"):<template id="djl-fill-X" data-target="X" data-status="ok"> <div dj-view data-djust-embedded="X">…rendered child…</div> </template> <script>window.djust.lazyFill('X')</script>The new
python/djust/static/djust/src/50-lazy-fill.jsmodule'swindow.djust.lazyFill(slotId)function scans for matching<dj-lazy-slot data-id="X">and replaces it with the template's contents. Idempotent on double-fire.data-trigger="visible"defers the actual replacement until the slot enters the viewport via IntersectionObserver.data-status="error"/"timeout"wraps the fill in<dj-error aria-live="polite">for screen-reader announcement.Sticky + lazy =
TemplateSyntaxErrorat tag eval — hard incompatibility per ADR §"Failure modes". Sticky preservation requires the slot to exist at mount-frame time so the WS reattach canreplaceWiththe stashed subtree; lazy renders the slot AFTER mount, so the stash-target doesn't exist when reattach runs.as_view()dispatch wiring —LiveView.as_viewis now overridden so that classes withstreaming_render = Truereturn an async view callable (viamarkcoroutinefunction) that routes GET toaget()when in real ASGI context. This is the wiring that makes PR-A's foundation actually active end-to-end. WSGI deployments fall back to syncdispatchviasync_to_async, preserving the Phase-1 cosmetic chunked response behavior. The ASGI/WSGI signal isisinstance(request, ASGIRequest)— accurate even when the sync testClientwraps the async view viaasync_to_sync(the earlier loop-presence check was fooled by that wrapping).Files:
python/djust/templatetags/live_tags.py(~210 LoClazy=branch with thunk closure),python/djust/mixins/template.py(~40 LoC Phase-5 thunk loop),python/djust/mixins/request.py(~15 LoC thunk transfer +_lazy_thunksreset + ASGIRequest-aware_is_asgi_context),python/djust/live_view.py(~50 LoCas_viewoverride). New:python/djust/static/djust/src/50-lazy-fill.js(~140 LoC client). 14 new cases intests/unit/test_live_render_lazy.pycover validation, placeholder emit, thunk stash, thunk closure including error + timeout envelopes. 2 new integration cases intests/integration/test_lazy_streaming_flow.pydrive the full pipeline (sync render → thunk transfer → arender_chunks Phase 1-5 → consumer drain) and assert the body-close-before-fills wire-format ordering.Foundation for PR-C (
asyncio.as_completedparallel render across thunks; closes #1043 umbrella).Async render-path foundation:
aget()+ChunkEmitter+arender_chunks()(v0.9.0 PR-A, ADR-015) — first PR of the v0.9.0 P2 streaming arc (#1043). Closes the v0.6.1 retro #116 doc-claim debt: Phase 1 was a regex-split-after-render with no real TTFB win; Phase 2 PR-A introduces the actual async render path sostreaming_render = Trueshell-flushes to the wire BEFOREget_context_data()runs.New module
python/djust/http_streaming.py(~230 LoC) provides theChunkEmitterclass — a per-request boundedasyncio.Queuewith backpressure, cancellation propagation viarequest_token, and aregister_thunk()API surface that PR-B ({% live_render lazy=True %}) will hook into. The emitter exposes__aiter__for direct consumption byStreamingHttpResponse.New
async def aget()onRequestMixin(~150 LoC) parallel to the existing syncget(). Wraps the sync render viasync_to_async(self.get)to produce the full HTML, then drivesarender_chunks()to push chunks through the emitter. Returns aStreamingHttpResponsewithX-Djust-Streaming: 1andX-Djust-Streaming-Phase: 2headers. ASGI disconnect watcher cancels the emitter when the client closes the connection.New
arender_chunks()async coroutine onTemplateMixin(~135 LoC) splits the rendered HTML at<div dj-root>boundaries into 4 chunks (shell-open / body-open / body-content / body-close) and pushes each viaemitter.emit()withawait asyncio.sleep(0)boundaries so ASGI flushes the shell to the wire before the body chunks are queued. Cooperative cancellation viaChunkEmitterCancelled. Single-chunk fallback for fragment templates (no<div dj-root>).streaming_render = False(default) stays on the syncHttpResponsepath. WSGI deployments fall back to the Phase-1 regex-split-after-render via_make_streaming_responseper the documented graceful-degrade contract.Files:
python/djust/http_streaming.py(new),python/djust/mixins/request.py(aget()+_is_asgi_context()),python/djust/mixins/template.py(arender_chunks()),docs/adr/015-phase-2-streaming.md(ADR promoted from.pipeline-state/feat-streaming-phase2-1043-adr-draft.md). 18 new test cases intests/unit/test_async_render_path.pycover ChunkEmitter basics + backpressure + cancellation,arender_chunks4-yield invariant + fragment fallback + mid-stream cancel,agetstreaming response shape + redirect passthrough + non-streaming fallback, and_get_queue_max_from_settingsdefaulting.PR-B (
{% live_render lazy=True %}user API) and PR-C (asyncio.as_completed()parallel render) ship on top of this foundation.{% live_render ... sticky=True %}auto-detects preserved stickies (closes #1032, ADR-014) — the v0.6.0 Sticky LiveViews work shipped Dashboard→Settings→Reports preservation but left a known limitation: returning to a page that declares the sticky inline (Dashboard → Settings → Dashboard) re-mounted the child instead of reattaching the survivor — audio playback and any in-flight state on the sticky child died.The v0.9.0 P1 1.0-blocker fix teaches the
{% live_render %}template tag to consult the consumer's_sticky_preservedregistry at render time. When a survivor exists for the resolvedsticky_id, the tag re-registers the survivor onto the new parent, marks the id in a newconsumer._sticky_auto_reattachedset, and emits a<dj-sticky-slot>placeholder rather than a fresh subtree. The consumer's existing slot scan + the client's existingreplaceWithreattach then complete the round-trip without ever callingmount()on the survivor again.No wire-protocol changes. No new transport (cookie/header/handshake) needed — the existing WS pipeline already carries survivor info to the exact moment the tag renders. Falls through to fresh-mount unchanged on the HTTP GET path (no
_ws_consumerback-reference) and on first-navigation (empty_sticky_preserved).Files:
python/djust/templatetags/live_tags.py(~30 LoC tag-side branch),python/djust/websocket.py(_sticky_auto_reattachedset init/reset + slot-scan skip-on-claim, ~12 LoC),docs/adr/014-sticky-liveview-autodetect.md(new ADR). 4 new cases inTestStickyAutoDetectintests/unit/test_live_render_tag.pycover no-consumer, empty-preserved, preserved-for-our-id, and preserved-for-other-id paths. 2 new integration cases intests/integration/test_sticky_redirect_flow.pydrive the full Dashboard→Dashboard auto-reattach pipeline (tag emit- consumer slot-scan skip-on-claim + survivor in
survivors_final) end-to-end through the existing_FakeConsumerrig.
- consumer slot-scan skip-on-claim + survivor in