djust 0.5.1rc1

Pre-releaseReleased

Added

  • Form & submit polish batch (v0.5.1 P2) — Three related form-UX primitives:

    • dj-no-submit="enter" — Prevent Enter-key form submission from text inputs. Fixes the #1 form UX annoyance where pressing Enter to confirm a field accidentally submits the whole form. Textareas (multi-line input), submit-button clicks, and modified keys (Shift+Enter, Ctrl+Enter) are unaffected. Supports comma-separated modes (currently only "enter") for future expansion. Document-level keydown listener — DOM morphs don't need re-registration. (python/djust/static/djust/src/34-form-polish.js)
    • dj-trigger-action + self.trigger_submit(selector) — Bridge successful djust validation to a native HTML form POST. Essential for OAuth redirects, payment gateway handoffs, and anywhere the final step needs a real browser POST. The server calls self.trigger_submit("#form-id") after validation passes; the client receives the push event, verifies the target form carries dj-trigger-action (explicit opt-in — refusal is logged in debug mode), and calls the form's native .submit(). (python/djust/mixins/push_events.py, python/djust/static/djust/src/34-form-polish.js)
    • dj-loading="event_name" shorthand — Declarative scoped loading indicator: <div dj-loading="search">Searching...</div> shows only while the search event is in-flight. Previously required combining dj-loading.show + dj-loading.for="event_name" with an inline style="display:none". The shorthand auto-hides the element on register (no inline style required) and treats the attribute value as both the event-scope and the implicit .show trigger. Coexists with the existing dj-loading.* modifier family. (python/djust/static/djust/src/10-loading-states.js)

    Tests: 11 JS test cases in tests/js/form_polish.test.js covering every happy path and failure mode; 4 Python tests in python/djust/tests/test_trigger_submit.py locking in the push-event shape. Client.js: 35 → 36 source modules (+~120 LOC JS, +~30 LOC Python). Scoped scoped-loading dj-loading="event" implementation reuses existing globalLoadingManager infrastructure — no duplication.

  • State & computation primitives batch (v0.5.1 P2) — Four small related primitives for derived state, dirty tracking, stable IDs, and cross-component context sharing:

    • Memoized @computed("dep1", "dep2")@computed now accepts an optional tuple of dependency attribute names. When given, the value is cached on the instance and only recomputed when any dep's identity or shallow content fingerprint changes (id + length + key subset matching _snapshot_assigns semantics). Plain @computed (no args) retains property semantics — recomputes every access. React useMemo equivalent. (python/djust/decorators.py)
    • Automatic dirty tracking — self.is_dirty / self.changed_fields / self.mark_clean() — Track which public view attributes have changed since a baseline captured after mount(). changed_fields returns a set of attr names that differ from the baseline; is_dirty is bool(changed_fields); mark_clean() resets the baseline (call after a successful save). Use cases: "unsaved changes" warnings (beforeunload), conditional save buttons, optimized handle_event that skips work when nothing changed. Respects static_assigns and ignores private attrs. The WebSocket consumer and the HTTP API dispatch view both capture the baseline after mount. (python/djust/live_view.py, python/djust/websocket.py, python/djust/api/dispatch.py)
    • Stable self.unique_id(suffix="") — React 19 useId equivalent. Returns a deterministic per-view ID stable across renders of the same logical position. Useful for aria-labelledby, form field IDs, and any element that needs a consistent identifier across re-renders. Format: djust-<viewslug>-<n>[-<suffix>]. Counter resets via reset_unique_ids() at render boundaries. (python/djust/live_view.py)
    • Component context sharing — self.provide_context(key, value) / self.consume_context(key, default=None) — React Context API equivalent. A parent view or component exposes a value under key; descendants look it up with consume_context, walking the _djust_context_parent chain. Scoped per render tree; clear_context_providers() resets. (python/djust/live_view.py) See docs/website/guides/state-primitives.md.
  • Auto-generated HTTP API from @event_handler (v0.5.1 P1 HEADLINE, ADR-008) — Opt-in @event_handler(expose_api=True) exposes a handler at POST /djust/api/<view_slug>/<handler_name>/ with an auto-generated OpenAPI 3.1 schema served at /djust/api/openapi.json. Unlocks non-browser callers (mobile, S2S, CLI, AI agents) without duplicating business logic — the HTTP transport is a thin adapter over the existing handler pipeline, reusing validate_handler_params(), check_view_auth(), check_handler_permission(), and the same _snapshot_assigns() / _compute_changed_keys() diff machinery the WebSocket path uses. One stack, one truth (manifesto #4). New package djust.api with DjustAPIDispatchView (dispatch view), api_patterns() (URL factory), OpenAPISchemaView (schema endpoint), SessionAuth + pluggable BaseAuth protocol (auth classes may opt out of CSRF via csrf_exempt = True), and a registry that walks LiveView subclasses with exposed handlers. LiveView gains two read-only contract attributes: api_name (stable URL slug) and api_auth_classes (auth class list). Response shape mirrors the WS assigns-diff: {"result": <return>, "assigns": {<changed public attrs>}}. Error shapes are structured with error / message / details — 400 validation, 401 unauth, 403 denied or CSRF fail, 404 unknown view/handler or handler not expose_api=True, 429 rate limit, 500 handler exception (exception messages logged server-side only, never leaked to the client). Rate limiting: HTTP uses a process-level LRU-capped token bucket keyed on (caller, handler_name) honoring the handler's @rate_limit settings; WebSocket continues to use its per-connection ConnectionRateLimiter. The two transports share rate/burst values but separate bucket storage — a caller using both draws from both independently (a shared-bucket refactor is tracked as a follow-up). manage.py djust_audit now lists every expose_api=True handler and flags any missing @permission_required — treat an exposed handler like @csrf_exempt. Out of scope per ADR-008: streaming responses, GraphQL batching, first-party token auth, Swagger UI hosting, per-handler URL customization. Full guide at docs/website/guides/http-api.md. (python/djust/api/, python/djust/decorators.py, python/djust/live_view.py, python/djust/management/commands/djust_audit.py)

  • Service worker core improvements — instant page shell + WebSocket reconnection bridge (v0.5.0 P3, opt-in) — Two independent SW features that close the v0.5.0 milestone. Both are OFF by default; users opt in explicitly via djust.registerServiceWorker({ instantShell: true, reconnectionBridge: true }) from their own init code. No auto-registration.

    • Instant page shell. The SW caches the first navigation's response split into a "shell" (everything outside <main>) and "main" (inside). Subsequent navigations serve the cached shell immediately with a <main data-djust-shell-placeholder="1"> placeholder; the client then fetches the current URL with X-Djust-Main-Only: 1 and swaps in the fresh <main> contents. Shell/main split uses a single non-greedy regex — nested <main> inside HTML comments or </main> inside CDATA are documented limitations (full HTML parser deferred). Server side honors the header via the new djust.middleware.DjustMainOnlyMiddleware, which extracts the first <main>…</main> inner HTML, updates Content-Length, and stamps X-Djust-Main-Only-Response: 1. The middleware only touches HTML responses; JSON / binary / streaming responses pass through unchanged. Ordering-safe — it can sit anywhere in MIDDLEWARE that sees the rendered response.
    • WebSocket reconnection bridge. Client-side wraps LiveViewWebSocket.sendMessage so that when ws.readyState !== OPEN the serialized payload is posted to the SW via postMessage({type: 'DJUST_BUFFER', connectionId, payload}) instead of being dropped. The SW stores messages in an in-memory Map keyed by connection id, capped at 50 per connection (oldest dropped). On reconnect the client fires DJUST_DRAIN; the SW returns the buffered payloads and the client replays each via ws.ws.send(). Per-page-load connection ids isolate buffers across tabs. IndexedDB persistence and server-side sequence-dedup replay are deferred to v0.6 (best-effort replay today).
    • Files: python/djust/static/djust/service-worker.js (new, standalone — NOT bundled into client.js), python/djust/static/djust/src/33-sw-registration.js (new, concatenated into client.js), python/djust/middleware.py (new), python/djust/config.py (new service_worker defaults sub-dict), tests in tests/js/service_worker.test.js (10 cases) and tests/unit/test_main_only_middleware.py (7 cases), full guide at docs/website/guides/service-worker.md.
  • UploadWriter — raw upload byte-stream access for direct-to-S3 / GCS streaming (Phoenix 1.0 parity, v0.5.0 P2) — New UploadWriter base class in djust.uploads with an open()write_chunk(bytes)close() -> Any / abort(error) lifecycle, wired into allow_upload(name, writer=MyWriter). When a writer is configured, binary WebSocket chunks are piped straight to the writer without buffering to disk or RAM — zero temp file, zero entry._chunks. Writers are instantiated lazily per upload on the first chunk (so abandoned uploads never open an S3 multipart upload), opened exactly once, fed write_chunk() per client frame, and finalized via close() whose return value is stored on UploadEntry.writer_result and rendered in the upload-state context as {{ entry.writer_result }}. Any failure (open or write_chunk raised, close() raised, size-limit exceeded, client cancelled, WebSocket disconnected via UploadManager.cleanup()) routes through abort(BaseException) with the raw exception so writers can release server-side resources (e.g. AbortMultipartUpload); abort() is wrapped to swallow its own exceptions so a failing S3 cleanup never propagates into the request path. Includes BufferedUploadWriter helper that accumulates client-sent 64 KB chunks until a configurable buffer_threshold (default 5 MB — S3 MPU minimum part size except for the last) and calls on_part(bytes, part_num) so subclasses work with S3-aligned parts without worrying about raw client chunk size. Legacy (no-writer=) disk-buffered path is untouched byte-for-byte — backward compatible. Documented in docs/website/guides/uploads.md with a full S3 multipart example. (python/djust/uploads.py, python/djust/websocket.py)

  • dj-ignore-attrs — per-element client-owned attribute opt-out (Phoenix 1.1 JS.ignore_attributes/1 parity, v0.5.0 P2) — Mark specific HTML attributes as client-owned so VDOM SetAttr patches skip them. <dialog dj-ignore-attrs="open"> prevents the server from resetting the open attribute that the browser manages; <div dj-ignore-attrs="data-lib-state, aria-expanded"> protects third-party JS state. Comma-separated list with whitespace tolerance. The guard sits inside applySinglePatch's case 'SetAttr' after the UNSAFE_KEYS check; the attribute write is skipped entirely (and breaks out of the case) when the element opts out. RemoveAttr is intentionally unaffected. Implementation: globalThis.djust.isIgnoredAttr(el, key) helper (~20 lines JS) plus a three-line check in the patch site. (python/djust/static/djust/src/31-ignore-attrs.js, python/djust/static/djust/src/12-vdom-patch.js)

  • {% colocated_hook %} template tag + runtime extraction (Phoenix 1.1 ColocatedHook parity, v0.5.0 P2) — Write hook JavaScript inline alongside the template that uses it, instead of in a separate file. {% colocated_hook "Chart" %}hook.mounted = function() { renderChart(this.el); };{% endcolocated_hook %} emits a <script type="djust/hook" data-hook="Chart"> tag with a /* COLOCATED HOOK: Chart */ auditor banner. The client runtime walks script[type="djust/hook"] elements on init and after each VDOM morph (reinitAfterDOMUpdate), registers each body as window.djust.hooks[name] via new Function, and marks the script with data-djust-hook-registered="1" so re-scans are idempotent. Optional namespacing via DJUST_CONFIG = {"hook_namespacing": "strict"} prefixes data-hook with <view_module>.<view_qualname> so two views can each define Chart without colliding; per-tag opt-out with {% colocated_hook "X" global %}. Namespacing is OFF by default for compat. Security: the body is template-author JS (same trust level as any other template JS); </script> / </SCRIPT> are escaped in the tag's render() to prevent premature tag close. Apps on strict CSP without 'unsafe-eval' should continue using the traditional registration pattern. (python/djust/static/djust/src/32-colocated-hooks.js, python/djust/templatetags/live_tags.py, python/djust/config.py, docs/website/guides/hooks.md)

  • Database change notifications — PostgreSQL LISTEN/NOTIFY → LiveView push (v0.5.0 P1) — Subscribe LiveViews to Postgres pg_notify channels so database changes push real-time updates to every connected user with zero explicit pub/sub wiring. Three APIs: @notify_on_save(channel="orders") model decorator hooks Django post_save / post_delete and emits NOTIFY <channel>, <json>; self.listen("orders") in mount() subscribes the view (joins a Channels group named djust_db_notify_<channel>); def handle_info(self, message) receives {"type": "db_notify", "channel": ..., "payload": {"pk": ..., "event": "save"|"delete", "model": "app.Model"}} and re-renders via the standard VDOM diff path. A process-wide PostgresNotifyListener owns one dedicated psycopg.AsyncConnection (outside Django's pool — long-lived LISTEN connections don't play nice with pgbouncer transaction pooling) and runs async for notify in conn.notifies():, bridging every NOTIFY into channel_layer.group_send(...). Channel names are strictly validated (^[a-z_][a-z0-9_]{0,62}$) at registration and listen time — load-bearing because Postgres NOTIFY doesn't accept bind parameters for the channel identifier. send_pg_notify(channel, payload) is a public helper for Celery tasks / management commands. Non-postgres backends no-op gracefully (debug-logged); self.listen() raises DatabaseNotificationNotSupported when psycopg or a postgres backend isn't available. Known limitation: notifications emitted while the listener's TCP connection is dropped are lost — listener auto-reconnects with 1s backoff and re-issues LISTEN for all subscribed channels, and WS mount() re-fetch handles the client-side recovery case. Documented in docs/website/guides/database-notifications.md. (python/djust/db/decorators.py, python/djust/db/notifications.py, python/djust/mixins/notifications.py, python/djust/websocket.py)

  • PyO3 getattr fallback for model attribute access (v0.5.0 P1 — Rust template engine parity) — Templates can now reference Django model instances passed through context without manual dict conversion. {{ user.username }} resolves via Python getattr when user is a raw Python object rather than a JSON-serialized dict. Implementation: Python's _sync_state_to_rust() builds a sidecar of non-JSON-friendly context values and forwards them via the new RustLiveView.set_raw_py_values() method; Rust's Context::resolve() tries the normal value-stack path first, then walks getattr on attached PyObjects one segment at a time. PyAttributeError (and any property-descriptor exceptions) are caught — missing attrs render as empty, matching Django's TEMPLATE_STRING_IF_INVALID default. Value stays Serialize-friendly (no Value::PyObject variant); sidecar lives outside the Value enum via Arc<HashMap<String, PyObject>> on Context. (crates/djust_core/src/context.rs, crates/djust_live/src/lib.rs, python/djust/mixins/rust_bridge.py)

  • register_assign_tag_handler() for context-mutating template tags (v0.5.0 P1 — Rust template engine parity) — New tag-handler variety complementing register_tag_handler (emits HTML) and register_block_tag_handler (wraps content). An assign tag's render(args, context) method returns a dict[str, Any] that's merged into the template context for subsequent sibling nodes — no HTML output. Enables {% assign slot var_name %}-style patterns. Supported inside {% for %} loops (per-iteration mutation). Registered via djust._rust.register_assign_tag_handler(name, handler). New Node::AssignTag variant; partial-renderer emits "*" wildcard dep so downstream nodes always re-render on context changes. (crates/djust_templates/src/registry.rs, crates/djust_templates/src/parser.rs, crates/djust_templates/src/renderer.rs) See docs/website/guides/template-cheatsheet.md.

  • dj-virtual — Virtual / windowed lists with DOM recycling (v0.5.0 P1) — Render only the visible slice of a large list, recycling DOM nodes as the user scrolls. <div dj-virtual="items" dj-virtual-item-height="48" dj-virtual-overscan="5" style="height: 600px; overflow: auto;"> keeps ~visible-plus-overscan children in the DOM even if the pool has 100K entries. Implementation: fixed-height windowing via transform: translateY(...) on an inner shell plus a hidden spacer for scrollbar length, scroll handler batched through requestAnimationFrame, real element identity preserved across scrolls for hook/framework compatibility. Integrates with the VDOM morph pipeline: new containers are picked up by reinitAfterDOMUpdate, and djust.refreshVirtualList(el) is available for explicit repaints. djust.teardownVirtualList(el) disconnects observers for unmounted containers. (python/djust/static/djust/src/29-virtual-list.js) See docs/website/guides/large-lists.md.

  • dj-viewport-top / dj-viewport-bottom — Bidirectional infinite scroll (Phoenix 1.0 parity, v0.5.0 P1) — Fire server events when the first or last child of a stream container enters the viewport via IntersectionObserver. <div dj-stream="messages" dj-viewport-top="load_older" dj-viewport-bottom="load_newer" dj-viewport-threshold="0.1">. Once-per-entry firing (matches Phoenix) via a data-dj-viewport-fired sentinel; call djust.resetViewport(container) or replace the sentinel child to re-arm. New server-side stream() limit=N kwarg and stream_prune(name, limit, edge) method emit a stream_prune op that trims children from the opposite edge so chat apps, activity feeds and log viewers can stream bidirectionally without unbounded DOM growth. (python/djust/static/djust/src/30-infinite-scroll.js, python/djust/static/djust/src/17-streaming.js, python/djust/mixins/streams.py)

  • assign_async / AsyncResult (v0.5.0 P1) — High-level async data loading inspired by Phoenix LiveView's assign_async. Call self.assign_async("metrics", self._load_metrics) in mount() (or any event handler); the attribute is set to AsyncResult.pending() immediately, the loader runs via the existing start_async infrastructure, and on completion the attribute becomes AsyncResult.succeeded(result) or AsyncResult.errored(exc). Templates read the three mutually-exclusive states via {% if metrics.loading %}…, {% if metrics.ok %}{{ metrics.result }}…, {% if metrics.failed %}{{ metrics.error }}…. Sync and async def loaders are both supported; multiple calls in the same handler load concurrently. Cancellation piggybacks on cancel_async("assign_async:<name>"). (python/djust/async_result.py, python/djust/mixins/async_work.py)

  • {% dj_suspense %} block tag for template-level loading boundaries (v0.5.0 P1) — Declarative counterpart to assign_async: wrap a section depending on one or more AsyncResult assigns, and the boundary emits a fallback while any are loading, an error div if any failed, or the body once all are ok. Explicit await="metrics,chart" syntax keeps the tag debuggable — no reflection magic. Fallback templates are loaded via Django's template loader; unspecified fallbacks render a minimal spinner. Nested suspense boundaries resolve independently. Registered alongside {% call %} in the Rust template engine — no parser/renderer changes. (python/djust/components/suspense.py, python/djust/components/rust_handlers.py) See docs/website/guides/loading-states.md.

  • Function components via @component decorator (v0.5.0 P1 batch) — Stateless Python render functions registerable as template-invokable components. @component def button(assigns): ... is callable from templates via {% call "button" variant="primary" %}Go{% endcall %} (with {% component %} as a synonymous alias). Closes the middle ground between raw HTML and full LiveComponent classes for the ~80% of UI pieces (buttons, cards, badges, icons) that are stateless. clear_components() helper exposed for tests. (python/djust/components/function_component.py, python/djust/__init__.py)

  • Declarative component assigns and slots (Phoenix.Component parity)Assign("variant", type=str, default="default", values=["primary", "danger"], required=True) and Slot("col", multiple=True) DSL, declared on a LiveComponent class attribute (assigns = [...] / slots = [...]) or on function components via @component(assigns=[...], slots=[...]). Validation runs at mount/invoke: required-missing raises AssignValidationError in DEBUG and warns in production, type coercion (str → int / bool / float) is automatic, enum violations via values= raise. Child-class assigns extend (and override by name) parent declarations via MRO walk. (python/djust/components/assigns.py, python/djust/components/base.py) See docs/website/guides/components.md.

  • Named slots with attributes via {% slot %} / {% render_slot %} tags — Parent templates pass named content blocks with attributes into components: {% call "card" %}{% slot header label="Title" %}Header{% endslot %}Body{% endcall %}. Multiple same-name slots collect into a list (essential for table columns, tab panels). Slots are exposed to the component as assigns["slots"] = {name: [{"attrs": {...}, "content": "..."}, ...]}. Non-slot content in the {% call %} body becomes children / inner_block. Implemented in pure Python via a sentinel-and-extract protocol — zero Rust parser/renderer changes. (python/djust/components/function_component.py)

Fixed

  • Attribute-context HTML escaping parity with Django (v0.5.0 P1 — Rust template engine parity) — Variables inside HTML attribute values now route through a dedicated html_escape_attr() that's guaranteed to escape "&quot; and '&#x27; (in addition to &/</>). Detection reuses the existing is_inside_html_tag_at() parser helper — the per-Node::Variable in_attr flag is computed at parse time, so renderer cost is a bool check. |safe still bypasses escaping in both attribute and text contexts. Today's behaviour is unchanged (the base html_escape already covered quotes) — this refactor makes the parse-time classification visible to the renderer so future changes to the default escape can't accidentally break attribute values like <a href="{{ url }}"> when url contains quotes. (crates/djust_templates/src/parser.rs, crates/djust_templates/src/filters.rs, crates/djust_templates/src/renderer.rs)
  • Inline conditional {{ x if cond else y }} now contributes deps to enclosing wrappers (#783, sibling bug) — Same failure mode as nested {% include %}: extract_from_nodes had no arm for Node::InlineIf, so its true_expr / condition / false_expr variables were silently dropped from the dep set of any surrounding {% if %} / {% for %} / {% with %}. Changing the condition alone (e.g. step_active in {% for s in steps %}<span class="{{ 'active' if step_active else 'idle' }}">) produced patches=[] and stale HTML. Fix: extract_from_nodes now extracts non-literal variables from all three InlineIf expressions.
  • Nested {% include %} now propagates wildcard dep to enclosing wrappers (#783) — Rust partial renderer reused the cached fragment of an {% if %} / {% for %} / {% with %} wrapping a nested {% include %}, because extract_from_nodes treated Include as having no variable references. When the included template referenced a context key that changed (e.g. {{ field_html.first_name|safe }}), the wrapper's dep set ({current_step_name}) did not intersect changed_keys ({field_html}), needs_render returned false, the cached HTML was reused, and the text-region fast-path compared byte-identical old/new HTML → patches=[] with diff_ms: 0. Manifested with deeply-nested WizardMixin templates ({% extends %} → {% block %} → {% if current_step_name == "..." %} → {% include "step_*.html" %}). Fix: extract_from_nodes now injects "*" into the variables map when it encounters a nested Include or CustomTag/BlockCustomTag during its walk, so wrapper deps include the wildcard and those nodes are always re-rendered. (crates/djust_templates/src/parser.rs)
  • _force_full_html now calls set_changed_keys so Rust partial renderer re-renders (#783) — When _force_full_html was set, _sync_state_to_rust() cleared prev_refs to force all context to Rust, but the set_changed_keys call was gated by if prev_refs which evaluated to False after clearing. Rust's partial renderer saw no changed_keys, fell back to full render with empty changed_indices, and the text-region fast-path compared identical old/new HTML → zero patches. Fix: set_changed_keys is now called when _force_full_html is set regardless of prev_refs.

Docs

  • ROADMAP correction: temporary_assigns is already implemented — The v0.5.0 ROADMAP entry claiming temporary_assigns was "completely absent from djust today" was inaccurate. The feature has shipped in earlier releases (LiveView._initialize_temporary_assigns / _reset_temporary_assigns, wired into the render cycle and excluded from change tracking). This PR adds a dedicated regression test (tests/unit/test_temporary_assigns.py) — prior coverage was indirect — and strikes through the ROADMAP entry.

Tests

  • Regression coverage for temporary_assignstests/unit/test_temporary_assigns.py covers reset-after-render semantics, default-value cloning per type (list / dict / set / scalar), idempotent initialization, pre-existing-attribute preservation, instance-level override, and the empty-mapping no-op path.
  • Unit tests for assign_async / AsyncResulttests/unit/test_assign_async.py (18 tests) covers state-flag invariants, frozen dataclass immutability, pending-is-set-immediately, success & failure propagation, multi-concurrent scheduling, cancellation interop with cancel_async, sync and async loaders, args/kwargs forwarding, and the generation-counter / stale-loader regression cases added in #793.
  • Unit tests for {% dj_suspense %}tests/unit/test_suspense.py (12 tests) covers ok → body, loading → fallback, failed → error-div, HTML-escaped error messages, no-await= passthrough, unknown / non-AsyncResult refs defaulting to loading, default spinner, Django template fallback, template-error graceful degradation, nesting, and whitespace-tolerant comma-separated lists.
  • Regression suite for |safe HTML blob diff (#783)tests/test_rust_vdom_safe_diff_783.py exercises the WizardMixin-style pattern where field_html is derived in get_context_data() from an instance attribute. Covers dict reassignment, in-place nested mutation, the _force_full_html codepath, an {% if %} branch swap, a {% extends %}/{% block %} inheritance chain, and the exact downstream-consumer-style {% extends %} + {% if %} + {% include %} structure that originally exhibited the bug. All variants assert non-empty VDOM patches on state change.
  • Dep-extractor hardening (#783 follow-up, P0) — Three-part hardening against silent dep-drop regressions in crates/djust_templates/src/parser.rs::extract_from_nodes:
    • Rust unit tests for extract_per_node_deps — table-driven assertions on representative AST shapes (simple Variable, If-wrapping-Include, For with tuple unpacking, With + body, InlineIf condition, nested For, Block recursion, plain Text). Explicit "*" wildcard membership checks for nested Include / CustomTag shapes.
    • Node variant exhaustiveness checksample_for_coverage exhaustive match on Node::* + sample_nodes() constructor + NO_VARS_VARIANTS allow-list. Any new Node variant fails to compile until the match is updated, and at runtime every non-allow-listed variant must produce a non-empty dep set (real vars or "*" wildcard). Makes silent dep-drops on future additions impossible.
    • Partial-render correctness harness (Python)TestPartialRenderCorrectness in tests/test_rust_vdom_safe_diff_783.py. Byte-equality oracle: for each of 6 wrapper shapes (no-wrapper, {% if %}, {% for %}, {% with %}, full #783 extends/if/include/safe chain, InlineIf-in-for), renders a mutation via the normal partial-render path then re-renders the same mutation with the Rust fragment cache cleared (clear_fragment_cache()) as a control. Any dep-miss that causes partial render to reuse a stale cached fragment diverges from the control and fails.
  • New PyO3 method DjustLiveView.clear_fragment_cache (test-only) (crates/djust_live/src/lib.rs) — clears node_html_cache, last_html, fragment_text_map, text_node_index while preserving last_vdom so the diff baseline is unchanged. Exclusively supports the partial-render correctness harness above; not intended for application use.

All releases · Atom feed