djust 0.9.0rc1

Pre-releaseReleased

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 its state_before baseline either deterministically (original params) or with caller-supplied override_params to fork a branched timeline.

    Builds on #1041's per-component capture: replay restores via restore_snapshot(view, snap, "before") which dispatches to view._components[id] instances. So a handler that reads self._components[id].value during replay sees the CAPTURED value, not the live one. The test test_replay_restores_component_state_before_invoking locks 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=False runs a "dry" replay — view is mutated for preview, buffer is unchanged.

    Handler-missing path: returns None and logs a warning (handler was renamed since the snapshot was captured). Handler-raises path: the new snapshot's error field 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_event function + __all__ extension). 7 new cases in TestReplayEvent in tests/unit/test_time_travel.py cover 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=True API → 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_state adds a reserved __components__ key holding a {component_id: {field: value}} nested dict. Components in self._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_snapshot detects __components__ in the snapshot and dispatches each {component_id: state} entry to the matching component in view._components via safe_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_snapshot helper + _capture_snapshot_state extension); python/djust/time_travel.py (~40 LoC: _COMPONENTS_SNAPSHOT_KEY constant + per-component restoration phase). 7 new cases in TestComponentLevelTimeTravel in tests/unit/test_time_travel.py cover 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 in arender_chunks Phase 5 (one thunk runs to completion before the next starts; total wall-clock time = sum of thunk durations). PR-C swaps the for-loop for asyncio.as_completed over 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-target on <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 through sync_to_async to 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 to asyncio.as_completed). 3 new wall-clock-sensitive tests in tests/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=True user API + as_view dispatch) → PR-C (parallel render).

  • {% live_render lazy=True %} capability + as_view dispatch 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, placeholder keys).

    At template-render time the tag emits a <dj-lazy-slot data-id="X" data-trigger="flush"> placeholder synchronously and registers a thunk on parent._lazy_thunks. RequestMixin.aget transfers the stash onto the ChunkEmitter after the sync render completes. Phase-5 of arender_chunks invokes 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.js module's window.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 = TemplateSyntaxError at tag eval — hard incompatibility per ADR §"Failure modes". Sticky preservation requires the slot to exist at mount-frame time so the WS reattach can replaceWith the stashed subtree; lazy renders the slot AFTER mount, so the stash-target doesn't exist when reattach runs.

    as_view() dispatch wiringLiveView.as_view is now overridden so that classes with streaming_render = True return an async view callable (via markcoroutinefunction) that routes GET to aget() 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 sync dispatch via sync_to_async, preserving the Phase-1 cosmetic chunked response behavior. The ASGI/WSGI signal is isinstance(request, ASGIRequest) — accurate even when the sync test Client wraps the async view via async_to_sync (the earlier loop-presence check was fooled by that wrapping).

    Files: python/djust/templatetags/live_tags.py (~210 LoC lazy= 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_thunks reset + ASGIRequest-aware _is_asgi_context), python/djust/live_view.py (~50 LoC as_view override). New: python/djust/static/djust/src/50-lazy-fill.js (~140 LoC client). 14 new cases in tests/unit/test_live_render_lazy.py cover validation, placeholder emit, thunk stash, thunk closure including error + timeout envelopes. 2 new integration cases in tests/integration/test_lazy_streaming_flow.py drive 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_completed parallel 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 so streaming_render = True shell-flushes to the wire BEFORE get_context_data() runs.

    New module python/djust/http_streaming.py (~230 LoC) provides the ChunkEmitter class — a per-request bounded asyncio.Queue with backpressure, cancellation propagation via request_token, and a register_thunk() API surface that PR-B ({% live_render lazy=True %}) will hook into. The emitter exposes __aiter__ for direct consumption by StreamingHttpResponse.

    New async def aget() on RequestMixin (~150 LoC) parallel to the existing sync get(). Wraps the sync render via sync_to_async(self.get) to produce the full HTML, then drives arender_chunks() to push chunks through the emitter. Returns a StreamingHttpResponse with X-Djust-Streaming: 1 and X-Djust-Streaming-Phase: 2 headers. ASGI disconnect watcher cancels the emitter when the client closes the connection.

    New arender_chunks() async coroutine on TemplateMixin (~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 via emitter.emit() with await asyncio.sleep(0) boundaries so ASGI flushes the shell to the wire before the body chunks are queued. Cooperative cancellation via ChunkEmitterCancelled. Single-chunk fallback for fragment templates (no <div dj-root>).

    streaming_render = False (default) stays on the sync HttpResponse path. WSGI deployments fall back to the Phase-1 regex-split-after-render via _make_streaming_response per 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 in tests/unit/test_async_render_path.py cover ChunkEmitter basics + backpressure + cancellation, arender_chunks 4-yield invariant + fragment fallback + mid-stream cancel, aget streaming response shape + redirect passthrough + non-streaming fallback, and _get_queue_max_from_settings defaulting.

    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_preserved registry at render time. When a survivor exists for the resolved sticky_id, the tag re-registers the survivor onto the new parent, marks the id in a new consumer._sticky_auto_reattached set, and emits a <dj-sticky-slot> placeholder rather than a fresh subtree. The consumer's existing slot scan + the client's existing replaceWith reattach then complete the round-trip without ever calling mount() 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_consumer back-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_reattached set init/reset + slot-scan skip-on-claim, ~12 LoC), docs/adr/014-sticky-liveview-autodetect.md (new ADR). 4 new cases in TestStickyAutoDetect in tests/unit/test_live_render_tag.py cover no-consumer, empty-preserved, preserved-for-our-id, and preserved-for-other-id paths. 2 new integration cases in tests/integration/test_sticky_redirect_flow.py drive 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 _FakeConsumer rig.

All releases · Atom feed