djust 1.1.0rc1

Pre-releaseSecurityReleased

This is a pre-release. djust 1.1.0 has shipped since: read the djust 1.1.0 release notes.

Added

  • Native author guide (LVN-V initial cut; ADR-019; #1581). New docs/native-author-guide.md: when to use native vs WebView (decision matrix), variant resolution mechanics, the v1 widget vocabulary in template syntax (example), connection-time ?platform= selection, status of shipped vs pending iterations across the LVN track + companion repos, and a migration sketch for djust-mobile-toga consumers adopting native incrementally. Cross-links to ADR-019, native-widget-vocabulary.md, all 5 LVN tracking issues, and the 3 companion repos. This is the first cut — full migration guide + v1.0 vocabulary lock land at LVN-III + LVN-IV implementation completion.

  • NativeRenderer.resolve_template wires the variant resolver (LVN-II PR-4; ADR-019; #1578). Per-renderer resolve_template(base) delegates to template_resolver.resolve_variant using the instance's output_format. The NotImplementedError from render_with_diff now names the resolved template ("medicare/home.swiftui.html" or fallback "medicare/home.html") — invaluable for debugging "is the variant being picked up?" before the Rust-side widget VDOM walker lands. LVN-II is now structurally complete: vocabulary (PR-1) + scaffold (PR-2) + resolver (PR-3) + wiring (PR-4). The remaining substantive piece (Rust-side widget VDOM differ that produces real Patch streams from native templates) ships in a follow-up sequence — closes #1578 from a "structural seam" perspective. 2 new tests.

  • Native template variant resolver (LVN-II PR-3; ADR-019; #1578). New python/djust/renderers/template_resolver.py with variant_name(base, output_format) and resolve_variant(base, output_format). Convention: foo.htmlfoo.swiftui.html / foo.compose.html. Resolver falls through to the base HTML name when a variant doesn't exist anywhere on the template loader path — handshake never errors on a missing variant. LVN-II PR-4 wires this into NativeRenderer.render_with_diff. 6 new tests.

  • NativeRenderer scaffold + swiftui / compose registry entries (LVN-II PR-2; ADR-019; #1578). New python/djust/renderers/native.py introduces NativeRenderer with SwiftUIRenderer / ComposeRenderer subclasses. Scaffold conforms to the Renderer Protocol (output_format per platform) but raises NotImplementedError from render_with_diff — the actual widget-tree walker ships in LVN-II PR-3. RENDERERS registry now resolves ?platform=swiftui to SwiftUIRenderer and ?platform=compose to ComposeRenderer; this routes native handshakes to a defined error today rather than silent HTML fallback (which would mask client-side misconfigs). 9 new tests in test_native_renderer_scaffold.py. Existing handshake test in test_renderer_handshake.py updated to reflect that swiftui is now registered.

  • Native widget vocabulary frozen at v1 (LVN-II PR-1; ADR-019; #1578). New python/djust/renderers/widgets.py exposes WIDGET_TAGS (frozenset of 12 widget tags — the SwiftUI ∩ Jetpack Compose intersection), EVENT_ATTRS (dj-tap, dj-change, dj-input), STYLE_ATTRS (padding, spacing, alignment, foregroundColor, font), and is_widget_tag(tag). New docs/native-widget-vocabulary.md is the human-readable spec with SwiftUI / Compose mapping table + SemVer commitment (additions require a minor bump + coordinated native-client release; removals are a major bump). Used by NativeRenderer (LVN-II PR-2) and mirrored in djust-native-ios / djust-native-android (LVN-III #1579 / LVN-IV #1580). 8 new pinning tests.

  • LiveViewConsumer handshake selects renderer via ?platform= (LVN-I PR-3; ADR-019; #1577). Completes the LVN-I track (Protocol + ViewRuntime field + handshake wiring). New RENDERERS registry in python/djust/renderers/__init__.py maps "html"HtmlRenderer; get_renderer_factory(platform) resolves a factory by ?platform= value (returns None for missing/unknown, so a typo never breaks a session — falls through to HtmlRenderer default at dispatch). LiveViewConsumer._get_runtime parses ?platform= from scope["query_string"] (ASGI bytes), resolves via the registry, and passes renderer_factory to ViewRuntime construction. Browser today sends no ?platform= → factory is None → byte-identical render. LVN-II (#1578) will register swiftui + compose and the handshake selection lights up. New tests: python/djust/tests/test_renderer_handshake.py (5 cases). Full djust suite: 2999 pass / 3 skipped / 0 fail (+8 net new LVN-I tests across PR-1/2/3).

  • ViewRuntime.__init__ accepts a renderer_factory kwarg (LVN-I PR-2; ADR-019; #1577). Plumbs a renderer factory through the transport-agnostic runtime introduced in ADR-016. Stored on the instance for PR-3 (handshake) to set based on the connection's ?platform= query param. Default None for full back-compat — existing call sites in python/djust/websocket.py:4248 and python/djust/sse.py:110 are unchanged. The dispatch site (TemplateMixin.render_with_diff:942) still constructs HtmlRenderer(self) inline; PR-3 will route through the runtime's factory. New test file python/djust/tests/test_runtime_renderer_param.py (3 cases: default-None, factory-stored, existing-callers-unchanged).

  • djust.renderers package — pluggable Renderer Protocol + default HtmlRenderer (LVN-I PR-1; ADR-019; #1577). Foundation iteration of the LiveView Native track. Introduces a structurally narrow Renderer Protocol (@runtime_checkable; output_format: str; render_with_diff(...) -> tuple[str, Optional[str], int]) so the server-side reactive lifecycle can dispatch to non-HTML targets (SwiftUI, Compose — LVN-II onward in #1578). HtmlRenderer wraps the existing Django-template + Rust VDOM pipeline; behavior is byte-identical to the pre-refactor inline call. TemplateMixin.render_with_diff at python/djust/mixins/template.py:942 now dispatches through HtmlRenderer(self).render_with_diff(...) instead of inlining self._rust_view.render_with_diff(). What's explicitly NOT in this PR: ViewRuntime plumbing (PR-2 of LVN-I) and ?platform= handshake parsing (PR-3); NativeRenderer + widget vocabulary (LVN-II / #1578); crates/djust_vdom (wire format unchanged); static/djust/client.js (browser client byte-identical). New tests: python/djust/tests/test_renderer_protocol.py (11 cases covering package imports, Protocol shape, HtmlRenderer conformance via @runtime_checkable isinstance, delegation to _rust_view, and the dispatch gate that the mixin routes through HtmlRenderer instead of the inline call). Full djust test suite: 2991 pass / 3 skipped / 0 fail (no regression). Prior art: ADR-016 (ViewRuntime + Transport — this is the third pluggability axis on the same refactor).

  • Mount-spine parity nets + 6 real-WebsocketCommunicator flip gap-tests for the WS mount convergence (#1911, ADR-022 Iter 3 Phase 3.0). The regression net the eventual mount flip (Phase 3.3b) will ride. python/djust/tests/test_ws_mount_flip_parity_1911.py characterizes the six mount behaviors the flip must preserve, driving each against the CURRENT bespoke handle_mount over a real channels WebsocketCommunicator (each passes now + must stay green through the flip = the parity proof, #1466/#1780/#1468): actor MOUNT (a use_actors view renders an actor-backed mount frame, NOT the SSE refusal — Finding D), sticky_hold-before-mount-frame ORDERING via live_redirect_mount (Finding B), Channels group_add server-push reachability (a broadcast to the mounted view's group reaches the session), periodic tick started at mount (a source="tick" frame arrives with no client event), optimistic_rules + upload_configs on the mount frame, and live_redirect re-mount idempotency (mount A → live_redirect to B → B actually mounts, not a no-op — THE Finding-A net: the bespoke path nulls self.view_instance before re-mounting, and a naive flip that forgets to also reset runtime.view_instance would silently no-op the re-mount since dispatch_mount early-returns when view_instance is not None). Each asserts intermediate state + has a gate-off/contrast sibling. python/djust/tests/test_transport_behavioral_parity.py grows the mount-spine nets (mount-stash + dirty-baseline pins, mount-async/push-drain parity, mount-frame wire-version parity per Finding C's no-arm baseline, a two-queues-not-_flush_all_pending source pin) and extends _WS_ONLY_MARKERS with the WS-only mount behaviors (create_session_actor, state_snapshot_signed, _find_sticky_slot_ids, tick_interval, register_view) so a future "moved to runtime" of one trips RED. No WS routing change: RUNTIME_OWNED_VERBS ({"url_change", "event"}) and handle_mount/handle_mount_batch are UNTOUCHED.

Changed

  • Automatic SPA navigation (dj-navigate) is ON by default as of v1.1 (ADR-021 Stage 3). LIVEVIEW_CONFIG["auto_navigate"] now defaults to True (it was opt-in / False through 1.0.x). With no configuration, {% djust_client_config %} emits the <meta name="djust-auto-navigate"> flag and auto-emits the route map (#1733), so the client SPA-navigates plain <a href> links whose path resolves in the (auth-filtered, #1758) route map — Turbo-Drive-style, zero djust attributes. It degrades gracefully: external / non-LiveView links and the full opt-out matrix (modifier/middle-click, target/download, hash-only, data-no-navigate) full-reload exactly as before. Native dj-navigate is now djust's canonical SPA-navigation model (no manual live_session + route-map wiring needed). Opt out with LIVEVIEW_CONFIG["auto_navigate"] = False (e.g. apps wiring their own external TurboNav). Tests: test_auto_navigate_meta_emitted_by_default (new default) + test_auto_navigate_meta_absent_when_opted_out.

  • CI: the Playwright browser-smoke canary is now a HARD merge gate, and the #1848 inline-script check is now a hard assertion (#1869, Action Tracker #314). The #1849/#1848 runtime-break canary (tests/playwright/test_browser_smoke.py, which drives /demos/browser-smoke/ and guards the 1.0.7 runtime-break class — a LiveView refused at WS mount, and an inline <script> inside the dj-root whose delegated listener never registers under the #1610 mount morph) was carved out of the already-non-blocking playwright-tests leg into its OWN dedicated browser-smoke CI job (no continue-on-error) and wired into the test-summary aggregate gate's AND-condition, so a re-introduced runtime break of either class now red-bars the PR (mirrors the demo-checks blocking-job pattern, #1708/#1713). Promoted per #1534 only after the canary shipped green on the runner across multiple PRs in the non-blocking leg. The rest of the playwright suite (loading_attribute / cache_decorator / draft_mode / nav_hooks) stays in the non-blocking playwright-tests leg — the full suite can be flaky; only this stable two-class canary gates. The inline-script (#1848) branch of the canary, previously a tolerated known-xfail (warn-not-fail when the inline <script> never ran), is flipped to a HARD assertion now that PR #1871 fixed #1848 (re-execute classic <script> on the #1610 mount morph via window.djust._runInsertedScripts); a future regression of that fix now hard-fails the now-gating canary.

  • WS mounts now route through ViewRuntime.dispatch_mount — THE MOUNT FLIP, the #1646 mount convergence COMPLETE (#1919, ADR-022 Iter 3 Phase 3.3b). "mount" joins "url_change" + "event" in RUNTIME_OWNED_VERBS, so receive() routes every WS mount frame through the single dispatch_messagedispatch_mount chokepoint, and the ~870-line bespoke handle_mount body is DELETED — reduced to a THIN SHIM over dispatch_mount (mirroring the event flip #1907 and handle_url_change). Phases 3.0-3.3a had already grown dispatch_mount into a functional superset (F22 view resolver, run_pre_mount_auth pre-mount auth+tenant via _check_auth, on_mount hooks, session + signed-snapshot state restore, post-mount object-permission, handle_params, actor mount, no-arm mount wire version, the sticky_hold pre-mount frame, the auth verdict→close finalize, the 2-queue mount-time drain) via WSConsumerTransport hooks. This PR is the atomic flip with the three load-bearing findings wired: (A) idempotency — the shim, the _dispatch_runtime_owned mount arm, disconnect, and the live_redirect teardown all null runtime.view_instance BEFORE dispatch, so a reconnect / live_redirect re-mount is never silently no-op'd by dispatch_mount's if view_instance is not None early-return (the #560-class landmine); (B) ownership inverts — mount CREATES the view (runtime→consumer), so the shim reads back self.view_instance = runtime.view_instance, and the WS post-mount consumer setup the bespoke body did but the runtime did NOT (server-push / presence / db_notify group_add, the periodic tick_interval task, the use_actors flag, the real-scope _websocket_path/_websocket_query_string stamps, the _sticky_auto_reattached reset) is folded into the now-LIVE WS on_view_mounted transport hook — made async to await group_add — Finding B residual; (C) mount wire version via the next_mount_version hook (the no-arm consumer counter). The object-perm denial now closes the socket via finalize_mount_auth (Finding E — the bespoke unconditional close(4403) had no runtime equivalent). A mount_batch bug the flip surfaced is also fixed: ViewRuntime._instantiate_view fire-and-forgot its error frame via asyncio.ensure_future, leaking a FAILED view's error into the NEXT survivor's collector (flipping a survivor to failed[]); it now stashes the frame and dispatch_mount await-sends it inside the correct _mount_one window. handle_mount_batch / _mount_one stay WS-only (the collector contract is unchanged; finalize_mount_auth still gates the redirect-verdict close on not _mounting_in_batch per #291/#1780). Boundary pins updated to the post-flip reality: the RUNTIME_OWNED_VERBS contract, the Concern-4 mount-orchestration count-canary (run_pre_mount_auth / object-perm / validated_host_from_scope converged onto runtime.py), _WS_ONLY_MARKERS (group_add / channel_layer / tick_interval moved to the runtime hook), and the handle_mount source-grep pins (snapshot sign/unsign, skip-html, _ensure_tenant-before-restore, has_ids, mount-url validation, next-version) moved to dispatch_mount; the fake consumers in test_sw_advanced.py / test_sw_advanced_flow.py gained a permissive _rate_limiter so they drive the runtime path. Gate-off-verified (#1468): neutering the Finding-A null makes the live_redirect re-mount net (test_ws_mount_flip_parity_1911.py::TestLiveRedirectRemountIdempotency) RED; neutering the on_view_mounted fold makes the group_add-reachability + tick-at-mount nets RED. Full CI-way suite (tests/ python/tests/ python/djust/tests/ -n auto): 8577 passed, 0 failed.

  • The 5 transport mount-hooks (#1916) are now WIRED into ViewRuntime.dispatch_mount — it is a functional SUPERSET of the WS handle_mount, and the hooks go LIVE for the SSE/runtime mount path (#1917, ADR-022 Iter 3 Phase 3.3a). The last build-up before the Phase 3.3b atomic flip. Routing stays bespoke — RUNTIME_OWNED_VERBS is UNCHANGED ({"url_change", "event"}), handle_mount / handle_mount_batch are UNTOUCHED (websocket.py has no diff) — but the dormant hooks are now called by dispatch_mount at their WS-faithful positions (read off handle_mount): (1) on_view_instantiated(view) right after instantiation (WS stamps _ws_consumer / _push_events_flush_callback / observability register_view / validated host; SSE no-op) (Finding B). (2) uses_actors_for_mount / dispatch_actor_mount (Finding D) — the hard actor REFUSAL is replaced: a WS use_actors view now RENDERS through the actor system at the render step (verbatim handle_mount ordering — after auth + mount() + handle_params, html sent without strip/extract, websocket.py:2691-2706); SSE keeps refusing (uses_actors_for_mount → False, so the structured use_actors is not supported over SSE envelope is now reached only when the transport does NOT support actor mounts). (3) next_mount_version(html, rust_version) (Finding C) — the mount-frame version routes through the NO-ARM hook (WS consumer._next_version() — establishes the baseline, does NOT arm request_html recovery so _recovery_html stays None; SSE returns the raw Rust render_with_diff() version, IMPLEMENTED here — the 3.2 SSE placeholder raised). The signature is widened to (html, rust_version=1) mirroring next_client_version so the runtime hands every transport the same inputs; the default keeps the 3.2 single-arg callers working. Crucially mount does NOT route through the ARMING next_client_version the event path uses. (4) on_mount_render_ready(view, html) (Finding B residual) runs after render, before the mount frame (WS sticky preservation + the sticky_hold frame emitted BEFORE the mount frame; SSE returns html unchanged). (5) finalize_mount_auth(view, verdict) (Finding E) on the three auth-block verdicts (_check_auth permission_denied + redirect; dispatch_mount run_on_mount_hooks redirect) — the runtime already sent the verdict frame + cleared view_instance, so the hook adds ONLY the transport-level close(4403) (WS unconditional for permission-denial, gated on not _mounting_in_batch for the redirect verdicts per #291/#1780; SSE no-op); it does NOT re-send the frame. Every hook is getattr-guarded so duck-typed test fakes (and the default-bearing Protocol) keep working. dispatch_mount is now a clean superset (Findings A/B prep) — the idempotency guard + view_instance ownership are untouched; the residual delta for the 3.3b flip is the routing flip + the A/B shim (the runtime.view_instance reset + read-back) only. New cases in TestRuntimeBasicMountParity, TestRuntimeActorMountParity, TestRuntimeNoArmVersionWiring, TestRuntimeAuthBlockFinalize, TestRuntimeStateRestoreParity (python/djust/tests/test_runtime_mount_parity_1917.py) — THE key 3.3a gate: drives dispatch_mount over a REAL WSConsumerTransport (direct-call shim, NOT via RUNTIME_OWNED_VERBS) and proves WS-equivalent mount for basic mount, ACTOR mount (renders not refuses), no-arm version, auth-block #291-not-in-batch, and state restore (Phase 3.1), plus two routing-untouched pins; gate-off-verified (#1468) — the actor branch off → the view is refused again, the next_mount_version wiring off → the wrong version is stamped. The Phase-3.2 DORMANT pins in python/djust/tests/test_transport_mount_hooks_1915.py are INVERTED to load-bearing WIRED pins (each hook is now referenced in dispatch_mount / the auth helper; SSE next_mount_version returns rust_version).

  • The 5 transport mount-hooks the WS-mount flip needs are now DEFINED — DORMANT scaffolding, not yet wired into dispatch_mount (#1915, ADR-022 Iter 3 Phase 3.2). Internal scaffolding PR — zero live behavior change. Mirrors how Phase 2.3a defined the event hooks (event_context / on_event_recorded / dispatch_actor_event) DORMANT before the event flip wired + routed them. The 5 hooks land on the Transport protocol (behavior-preserving no-op / refuse defaults), WSConsumerTransport (the real WS impl, each encapsulating the verbatim bespoke handle_mount logic for its cited site), and SSESessionTransport (no-op / raw / refuse), addressing ADR-022 Iter 3 Findings B/C/D/E: (1) on_view_instantiated(view) — WS stamps view._ws_consumer + wires _push_events_flush_callback (websocket.py:2128/2134-2135), registers the view in the observability registry (2161-2167), and stashes the validated _websocket_host/_websocket_secure (2243-2270) (Finding B); SSE: no-op. (2) uses_actors_for_mount(view) + dispatch_actor_mount(view, data) — WS: use_actors and create_session_actor is not None (websocket.py:2213) → create_session_actor + actor_handle.mount(){html, version} (2213-2217/2665-2706), verbatim (Finding D); SSE: False / raise (the dispatch_mount refusal stays). (3) next_mount_version(html) — WS returns consumer._next_version(), the NO-ARM counter handle_mount uses (websocket.py:2746); crucially it does NOT call _next_version_armed / _arm_recovery (a mount ESTABLISHES the client VDOM baseline and has no prior frame to recover to — distinct from next_client_version, which arms for render-SEND frames), so _recovery_html stays None after a mount (Finding C / #1817); SSE: raw Rust version (placeholder, raises until 3.3a wires it). (4) on_mount_render_ready(view, html) — WS: sticky preservation (_find_sticky_slot_ids survivor scan + _register_child re-registration) + the sticky_hold frame emitted BEFORE the mount frame (websocket.py:2080-2082/2836-2903), returning html unchanged; SSE: returns html unchanged (Finding B residual). (5) finalize_mount_auth(view, verdict) — WS: the transport-level socket close(4403) the bespoke auth-finalization performs (websocket.py:2337-2401), GATED on not consumer._mounting_in_batch for the redirect verdicts so a batched login-required view does NOT drop the shared socket's sibling mounts (#291/#1780), unconditional for a permission-denial; SSE: no socket to drop → no-op (the runtime-sent error/navigate frame is the SSE finalization). DORMANT: dispatch_mount does NOT call any of these yet (Phase 3.3a wires them in) and the WS bespoke handle_mount / handle_mount_batch keep doing all of this inline (untouched until the Phase 3.3b flip); RUNTIME_OWNED_VERBS / WS routing are UNTOUCHED; websocket.py has no production diff. New cases in python/djust/tests/test_transport_mount_hooks_1915.py (Test... MockTransport unit tests per hook + real-WebsocketCommunicator tests exercising the WS impls in isolation against a genuinely-mounted consumer — uses_actors_for_mount True for a use_actors view, next_mount_version returns the consumer counter WITHOUT arming recovery, finalize_mount_auth does NOT close when _mounting_in_batch=True) + DORMANT pins (dispatch_mount doesn't reference the hooks, still stamps the raw Rust version + still refuses actor mounts; handle_mount still does the work inline). All gate-off-verified (#1468): arming recovery in next_mount_version reds the 3 no-arm tests, removing the not _mounting_in_batch gate reds the in-batch tests, no-op'ing on_view_instantiated reds the stamp test. The anti-drift _WS_ONLY_MARKERS pin (test_transport_behavioral_parity.py) drops create_session_actor / _find_sticky_slot_ids / register_view (no longer WS-only — the dormant WS hooks now reference them in runtime.py), mirroring the Phase-3.1 state_snapshot_signed move.

  • ViewRuntime.dispatch_mount grew the transport-agnostic mount STATE-RESTORE + on_mount hooks WebSocket handle_mount has, going LIVE for SSE mount (#1913, ADR-022 Iter 3 Phase 3.1). Second PR of the WS mount convergence (after Phase 3.0's cheap grows, #1911). Three ports, each gated on enable_state_snapshot (#1552) so default views are unaffected: (1) run_on_mount_hooks (websocket.py:2383-2401) runs the registered on_mount hooks after the pre-mount auth sequence + before mount(); a hook that returns a redirect URL emits a navigate frame, clears the unmounted view, and aborts — transport-agnostically (no socket close(); that belongs to the Phase 3.2/3.3a finalize_mount_auth hook, matching the runtime's existing auth-redirect handling in _check_auth). (2) Session-saved-state restore (websocket.py:2424-2474) reattaches the public + private state + per-process side-effect registrations (_restore_upload_configs / _restore_presence / _restore_listen_channels, hasattr-guarded) + component state the per-event session-save (#1466) wrote, on a plain reconnect-mount — in lieu of mount(). (3) The has_prerenderedskip_html_for_resume resume optimization Phase 3.0 wired (but left dormant) now ACTIVATES: a restore (session or signed-snapshot) sets the new _mounted_from_restore framework flag, so a resuming client that already holds the DOM skips the redundant mount-HTML swap (the version still flows so patches stay in sync). _mounted_from_restore is initialized in LiveView.__init__ BEFORE the _framework_attrs snapshot (#1393) so it is reset on reconnect and never persisted. Blast radius: SSE mount (which uses dispatch_mount) + the runtime; websocket.py has no diff, RUNTIME_OWNED_VERBS / handle_mount / handle_mount_batch are unchanged. The anti-drift _WS_ONLY_MARKERS pin drops state_snapshot_signed (no longer WS-only — now on the runtime too) and the live_view.py setattr-whitelist line numbers shift +11. New cases in python/djust/tests/test_runtime_mount_state_restore_1913.py (TestRuntimeSessionRestore, TestRuntimeOnMountHooks): an opt-in view's session-saved state restores on a runtime reconnect-mount while a default view ignores it (#1552 gate-off, RED when the gate is dropped); an on_mount redirect emits a navigate frame + aborts (RED when the redirect handling is gated off).

  • ViewRuntime.dispatch_mount grew the transport-agnostic mount behaviors WebSocket handle_mount has, going LIVE for SSE mount (#1911, ADR-022 Iter 3 Phase 3.0). First PR of the WS mount convergence — grows the runtime mount path toward a functional superset of handle_mount over zero-WS-routing-risk PRs (the eventual flip is Phase 3.3b). Five grows, each ported from its WS site, gate-off-verified (#1468): (1) the _djust_mount_request / _djust_mount_kwargs stash (#1895, websocket.py:2596, placed after mount() + object-perm, before handle_params) — the runtime's OWN per-event session-save fallback (runtime.py:2030/2109) already READS this attr to discover the save session + liveview_{path} namespace, so the stash makes that fallback live on the converged path instead of silently degrading to the scope session; (2) _snapshot_user_private_attrs + _capture_dirty_baseline post-mount (websocket.py:2598-2603); (3) has_prerenderedskip_html_for_resume machinery (websocket.py:2804-2816), dormant until Phase 3.1 wires session-restore (the _mounted_from_restore flag defaults False, so HTML is always sent today); (4) optimistic_rules (DEP-002) + upload_configs on the mount frame (websocket.py:2823-2834, via a new runtime _extract_optimistic_rules mirror); (5) the mount-time _flush_push_events() + _dispatch_async_work(None) drain (websocket.py:2916, #1280/#1283) — ONLY those two queues, NOT the 8-queue _flush_all_pending the turn-end event path uses (mount establishes a baseline, it does not run a full event turn-end flush), with the #1391 source-grep pin MOVED to the runtime location in test_handle_mount_drains_queues.py. Blast radius: SSE mount (which uses dispatch_mount) + the runtime; websocket.py has no diff and RUNTIME_OWNED_VERBS is unchanged. Every grow has a gate-off witness in test_transport_behavioral_parity.py (7/7 verified RED). New cases in TestMountStashAndBaselines, TestMountAsyncAndPushDrain, TestMountFrameOptimisticAndUpload, TestMountFrameWireVersion.

  • THE FLIP: every WebSocket event now routes through ViewRuntime.dispatch_event — the bespoke _handle_event_inner is deleted (#1907, ADR-022 Iter 2 Phase 2.3b). The atomic moment of the event-path convergence (the #1646 cure: one event path, not two). "event" is added to RUNTIME_OWNED_VERBS (now {"url_change", "event"}), so receive() routes every WS event through the single ViewRuntime.dispatch_message chokepoint; handle_event becomes a thin shim over runtime.dispatch_event (mirroring handle_url_change); and the ~1170-line bespoke _handle_event_inner — the WS-only twin the runtime grew to a functional superset in Phase 2.3a (#1900/#1902/#1904/#1906) — is removed. The residual observability the bespoke handler owned is folded onto two new Transport hooks (SSE no-op): on_render_emitted carries the production-visible DJE-053 warning (#1079 — it MUST survive, and does) plus the _emit_full_html_update signal on the no-patch render branch, and on_handler_timing carries the record_handler_timing percentile telemetry; cache_request_id was already threaded through the runtime render path. The flip surfaced + fixed three parallel-path-drift regressions now that the runtime event path IS the WS event path: (1) ViewRuntime._flush_navigation is now await-ed (was fire-and-forget) and (2) the skip-render branch now calls _flush_all_pending, so a live_redirect() / navigation command queued by a state-unchanging handler still emits its navigation frame within the event turn (WS parity); and (3) the runtime's _dispatch_event_render now records a time-travel snapshot with error="permission_denied" / "validation_failed" on the security-rejected + validation-rejected early-return paths (record_event_start moved BEFORE the security check) — the bespoke _handle_event_inner recorded these for the debug panel, and the first flip pass dropped them for non-actor views (caught by tests/integration/test_time_travel_flow.py::test_permission_denied_view_handler_records_with_error). Boundary pins updated (RUNTIME_OWNED_VERBS contract, the event routing pin, the _handle_event_inner-deleted assertion) and the WS-source pins (1465 save-block, 1785 recovery-arming, 1788 wire-version count, 1802 sticky-child) redirected to the runtime where the behavior now lives. New TestResidualFoldObservability (DJE-053 + record_handler_timing survival, with reason/version gate-off siblings) and a WebsocketCommunicator regression for start_async / @background streaming its source="async" result over the runtime async path. Gate-off (#1468): removing "event" from RUNTIME_OWNED_VERBS makes all 11 test_ws_event_flip_parity_1896 behaviors fail with Unknown message type: event (the bespoke elif is gone) — proving the set membership is the only switch. The DEBUG-only debug-panel payload + cosmetic consumer attrs are deferred to #1908 (inert in production). Full suite green the way CI runs it (tests/ + python/tests/ = 4732 passed; python/djust/tests/ = 3750 passed; 0 failed, 21 skipped); the entire WS event regression net (reconnect-state #1465, sticky-child #1802/#1813, reauth #1777, send-version #1788, recovery-staleness #1817, url-change wire-version #1858, transport-hardening F21/F17, ratelimit-per-caller F27/F28) stays green.

  • ViewRuntime async-result frames now carry source="async", reconciling them with the WebSocket _run_async_work frames; and the dead use_binary framing path is confirmed + pinned (#1905, ADR-022 Iter 2 Phase 2.3a). Two folds finishing the 2.3a parity before the 2.3b WS-event flip. (1) async source="async" reconcileViewRuntime._render_async_result (the start_async / @background completion render shared by the success + error paths) emitted patch / html_update frames with NO source tag, while the WS _run_async_work tags all four of its frames source="async" (websocket.py:1166/1186/1223/1238). The client uses source to distinguish an out-of-band background-completion update from the in-turn source="event" response, so the runtime frames were the lone untagged twin — a #1646 parallel-path drift INSIDE the convergence target. Both runtime async-result branches now stamp source="async". LIVE for SSE + url_change async work (both use the runtime async dispatcher today); WS picks it up post-flip (Phase 2.3b). (2) binary-framing confirmconsumer.use_binary is dead: initialized to False at websocket.py:580 ('MessagePack support TODO') and never set True anywhere in the package; the only honoring site is _send_update's binary branch (websocket.py:1391), which WSConsumerTransport.send does NOT traverse (it calls consumer.send_json, always JSON). DESCOPED (no new binary path invented) + PINNED so a future enable is a deliberate, tested change: a guard test asserts WSConsumerTransport.send emits JSON via send_json (matching live WS), plus a source-grep pin that no production module assigns use_binary = True. No change to RUNTIME_OWNED_VERBS / WS routing; WS _handle_event_inner's async/binary paths stay on the bespoke handler until 2.3b; websocket.py has no diff. New cases in TestAsyncSourceReconcile / TestBinaryFramingConfirm (python/djust/tests/test_runtime_reauth_async_1905.py): real-SSE end-to-end (a start_async completion frame carries source="async") + unit (both branches tagged) + the JSON-emit + source-grep pins, with a gate-off witness (#1468) — removing the source="async" tag makes the SSE end-to-end + unit assertions RED. test_async_integration + test_sse_runtime_convergence_1887 stay green.

  • ViewRuntime gained the transport-agnostic {% dj_activity %} deferral WebSocket has — a defer-when-hidden gate + a lock-free deferred re-dispatcher — and it goes LIVE for SSE events (a parity improvement) (#1903, ADR-022 Iter 2 Phase 2.3a). The runtime event path lacked dj_activity deferral entirely: an event targeting a HIDDEN (non-eager) {% dj_activity %} region should be queued + acked with a no-op (no render) and replayed when the panel next shows, exactly as the bespoke WS _handle_event_inner does (websocket.py:3254-3273 gate + 4290-4294 flush). Two parts: (1) GateViewRuntime._dispatch_event_render (after embedded-child routing, before security validation) replicates the WS gate VERBATIM, reusing the SAME transport-agnostic ActivityMixin view methods (is_activity_visible / _is_activity_eager / _queue_deferred_activity_event); a hidden-region event is queued and answered with the runtime's self-describing noop (type/source/event_name/ref) and no render. (2) Flush + lock-free re-dispatcher (option (a)) — after a render that may flip visibility (BOTH the skip-render and render arms, mirroring the WS post-turn flush), ViewRuntime._flush_deferred_activity_events() hands the runtime ITSELF to the consumer-blind ActivityMixin._flush_deferred_activity_events as the _dispatch_single_event provider, so mixins/activity.py is UNCHANGED (the flush already accepts any object exposing that method). The new ViewRuntime._dispatch_single_event(target_view, event_name, params, event_ref=None) re-runs validate → handler → render for one queued event WITHOUT acquiring a lock and WITHOUT re-entering event_context — it already runs inside the borrowed context (which on WS holds the consumer _render_lock; re-acquiring the non-reentrant asyncio.Lock would deadlock, the websocket.py:1467 contract). A denied queued event is re-validated and dropped (WS flush per-event parity). Live behavior: this goes LIVE for SSE events — they route through dispatch_event since Iter 1 (#1887), so SSE events now respect dj_activity deferral (the parity improvement); a no-op for SSE views with no activity region (zero-cost when unused). WS events are UNAFFECTED — the bespoke _handle_event_inner gate/flush stays until Phase 2.3b; RUNTIME_OWNED_VERBS / WS routing are UNTOUCHED; websocket.py has no diff. New suite python/djust/tests/test_runtime_dj_activity_1903.py — direct-runtime (MockTransport) + real-SSE end-to-end, each reproduce-first + gate-off (#1468): hidden-activity event → queued + noop (no render); flip-visible → the queued event drains in the same round-trip (2nd frame); no-activity view → renders normally; the re-dispatcher runs inside the borrowed context with no re-entry (no-deadlock proof, asserted via a re-entry-recording mock context); a denied queued event is re-validated + dropped; plus structural pins (gate lives in _dispatch_event_render; re-dispatcher body is lock-free; the flush passes the runtime as the dispatcher). Gate-off verified: disabling the gate makes the hidden-deferral + flip-drain tests RED; disabling the flush makes the flip-drain tests RED. The existing WS dj_activity behavior (tests/unit/test_activity.py), the #1896 parity net (bespoke path, unchanged), and test_sse_runtime_convergence_1887 stay green.

  • ViewRuntime gained an actor-event transport hook (transport.uses_actors() + transport.dispatch_actor_event()) so a use_actors view's events route through the per-session Rust actor on the runtime path too — DORMANT until the Phase 2.3b WS-event flip (#1901, ADR-022 Iter 2 Phase 2.3a). The load-bearing fold the WS-event flip sits on. ViewRuntime.dispatch_event had NO actor branch, while the use_actors guard lived ONLY in dispatch_mount (which refuses SSE outright). A WS view mounts in actor mode (use_actors=True + a created actor_handle); once Phase 2.3b routes WS events through the runtime, such a view's events would have hit dispatch_event with no actor branch and silently run the handler IN-PROCESS via the normal render path, desyncing the actor's server-side diff baseline. Two new Transport hooks close the gap: (1) uses_actors(view)WSConsumerTransport returns consumer.use_actors and consumer.actor_handle is not None (the exact precondition of the bespoke WS actor block, websocket.py:3282), SSESessionTransport returns False (SSE has no bidirectional actor channel and dispatch_mount refuses use_actors mounts, runtime.py:602); (2) dispatch_actor_event(view, event_name, params, *, event_ref, cache_request_id)WSConsumerTransport runs the bespoke WS actor block (websocket.py:3282-3379) VERBATIM against the consumer (time-travel record/push in a finally, the shared _validate_event_security + validate_handler_params checks, actor_handle.event(), patch/HTML framing stamped with the consumer-owned wire version consumer._next_version() — the actor's internal result['version'] is IGNORED for the wire, #1788 — error handling, and the v0.7.0 deferred-activity flush), SSESessionTransport raises NotImplementedError (never called — uses_actors is False). Wired into _dispatch_event_inner BEFORE event_context (the actor block holds no render lock, matching WS), gated on uses_actors(view) AND the event NOT being routed to a sticky child — the WS not is_embedded_child_target mutual exclusion (websocket.py:3280-3282); per #1467 a component_id event does NOT reassign the target view and the WS actor block has no component handling, so a component_id event on a use_actors view goes through the actor (parity), and only a view_id resolving to a DIFFERENT child excludes it (_event_routes_to_sticky_child peeks at view_id WITHOUT consuming it, so the non-actor sticky-child routing still pops it). Zero live-behavior change: uses_actors is False for both live transports today (WS events still run on the bespoke _handle_event_inner; SSE refuses actor mounts), so no live event turn reaches the hook until 2.3b. WS routing (RUNTIME_OWNED_VERBS) + the WS _handle_event_inner actor block are UNTOUCHED (they stay until 2.3b); websocket.py has no diff. New direct-runtime suite python/djust/tests/test_transport_actor_event_1901.py (12 cases) builds a WSConsumerTransport over a fake consumer with use_actors=True + a fake actor_handle and asserts dispatch_event routes to dispatch_actor_event (the actor's .event() is called + the framed result is sent via _send_update with the consumer-owned wire version, NOT the in-process handler), uses_actors False for SSE + a WS consumer without actor_handle, a view_id-routed event skips the actor while a view_id-equals-top event still routes to it, and the SSE dispatch_actor_event raises; gate-off verified (#1468) — forcing uses_actors to always return False makes the actor-routing cases go RED (the event falls to the in-process render path). The existing #1896 actor-parity test (test_ws_event_flip_parity_1896.py, the bespoke WS path) + the #1899 event_context suite stay green.

  • ViewRuntime now BORROWS the consumer's render-lock + origin-channel + observability scope for each event via a new transport.event_context() hook, and the dead runtime-local _render_lock is deleted (#1899, ADR-022 Iter 2 Phase 2.3a). Foundational fold the dj_activity re-dispatcher + the 2.3b WS-event flip sit on. Two load-bearing flip-scope findings drove this: (1) ViewRuntime._render_lock was DEAD CODE — declared in __init__, never acquired anywhere — and is removed; the runtime CANNOT own the render lock, because render serialization is consumer-owned (LiveViewConsumer._render_lock, websocket.py:619) and SHARED with the WS-only _run_tick / server_push / db_notify render loops, so a runtime-local lock would be a different object and could not serialize against ticks (the #560 version-interleave bug). (2) So a new async-CM transport.event_context(view) on the Transport protocol + both adapters lets the runtime borrow the consumer's EXISTING lock: WSConsumerTransport.event_context on enter mirrors _handle_event_inner verbatim — await consumer._render_lock.acquire() (the existing object, not a new one), _processing_user_event = True, set the #1677 origin-channel contextvar to consumer.channel_name, start a PerformanceTracker + the SQL capture_for_event scope (websocket.py:3393-3400 / 3150-3154 / 3469-3475); on exit (finally) it resets the origin token, clears _processing_user_event, RELEASES the borrowed lock, and stops the SQL capture + tracker (websocket.py:4311-4313). SSESessionTransport.event_context is a no-op async CM (SSE runs single-threaded off the HTTP request — no concurrent tick/push loop to serialize against). The event handler+render body of _dispatch_event_inner is extracted into _dispatch_event_render and run inside async with self.transport.event_context(self.view_instance): (the view-mounted check stays OUTSIDE the context — a non-None view is needed to borrow its lock, matching WS, which acquires only after the view exists; a future actor-event branch will run OUTSIDE the context, matching WS where the actor block holds no lock). Zero WS-routing risk, no behavior change for current consumers: RUNTIME_OWNED_VERBS + _handle_event_inner are UNTOUCHED, and dispatch_url_change / _dispatch_url_change_inner are a SEPARATE path (untouched) — so this affects ONLY SSE events (the no-op context) and WS events (not routed through the runtime until the Phase 2.3b flip); url_change is unaffected. New direct-runtime suite python/djust/tests/test_transport_event_context_1899.py asserts the WS context borrows the consumer's EXISTING lock object (held inside, released after — incl. on exception), _processing_user_event True-inside/False-after, origin token set+reset, tracker current-inside/cleared-after; the SSE context is a no-op; ViewRuntime no longer owns a _render_lock; with a gate-off sibling (#1468 — a non-acquiring context makes the held-inside assertion go RED). The two existing source-grep pins (save-block gate, 5-grows enumeration) follow the body to _dispatch_event_render; four existing runtime transport mocks grow a no-op event_context.

  • The runtime event spine gained the three transport-agnostic per-event PERSISTENCE subsystems WebSocket has — time-travel record, session state-save (#1466), and sticky-child state-save (ADR-018) (#1894, ADR-022 Iter 2 Phase 2.2). Third PR of the 4-phase WS-event convergence split. ViewRuntime now records + persists per-event state the way the bespoke WS _handle_event_inner does, so the Phase 2.3 final flip (routing WS events through the runtime) persists identically: (1) time-travel recordrecord_event_start / record_event_end wrap the handler call in the single-view, component, and sticky-child branches, scoped per #1467 (component records on the PARENT view since LiveComponents have no separate buffer; a sticky-child records on the CHILD), finalized in a finally so a raising/permission-denied handler still appears in the debug panel; (2) session state-save #1466ViewRuntime._persist_state_after_event mirrors the WS save (private attrs first, then public get_context_data(), then components), gated on top-level-view identity AND enable_state_snapshot (#1552 — default views MUST NOT persist, since unconditional saves left async session I/O in flight that a host snapshot captured unrecoverably) and bounded by a 150ms asyncio.wait_for (#1475); (3) sticky-child state-save ADR-018ViewRuntime._persist_sticky_child_after_event persists a view_id-routed child under its stable sticky key on the both-opt-in predicate (sticky_child_should_persist), with the one-shot opt-in-mismatch warning (warn_sticky_child_optin_skip) in the else-branch. New Transport hook on_event_recorded(view, snapshot) replaces the WS _maybe_push_tt_event direct send: WSConsumerTransport delegates to the consumer's existing _maybe_push_tt_event (single-sourcing the DEBUG-gated time_travel_event frame), SSESessionTransport no-ops (no SSE debug panel today). A runtime-side #1466 source-grep pin (test_runtime_save_block_present_and_gated) asserts the SAME gate / key-shape / 150ms-bound strings the WS pin asserts, so drift between the two save gates goes red on whichever lost the string. No behavior change for current WS consumers — the WS save-block source in websocket.py is UNTOUCHED (the #1466/#1552 grep-pins in test_ws_reconnect_state_1465.py:119/313/320 stay green; event stays out of RUNTIME_OWNED_VERBS, the WS flip is Phase 2.3). New direct-runtime suite python/djust/tests/test_runtime_state_save_tt_1894.py (12 cases) drives runtime.dispatch_event against a MockTransport; each subsystem has a reproduce-first + gate-off pair (#1468) — removing the enable_state_snapshot gate makes a default view wrongly persist (RED), neutering the time-travel record drops the snapshot + hook (RED), and disabling the hook dispatch makes the on_event_recorded assertion fail (RED). Existing WS + runtime suites stay green (test_ws_reconnect_state_1465, test_sticky_child_recovery_1813, test_time_travel.py, test_time_travel_flow.py, test_runtime_child_routing_1892).

  • The runtime event spine gained the three transport-agnostic child-routing subsystems WebSocket has — component_id LiveComponent, view_id sticky-child, and embedded-child render (#1892, ADR-022 Iter 2 Phase 2.1). Second PR of the 4-phase WS-event convergence split. ViewRuntime._dispatch_event_inner now routes embedded children before the single-view path, mirroring the bespoke WS _handle_event_inner subsystems the runtime previously lacked entirely: (1) a view_id-targeted event resolves a sticky/embedded child via _get_all_child_views(), validates the handler against the CHILD, renders the child subtree, and emits a scoped embedded_update {view_id, html, event_name} frame — the client-supplied view_id is never echoed into the user-facing error (sanitize_for_log in the structured extra only, verbatim from WS); (2) a component_id-targeted event resolves a child LiveComponent via _components, validates the handler against the COMPONENT (not the parent), notifies the PARENT's waiters with component_id injected (ADR-002), and emits a parent-scoped full-HTML component_event frame — per #1467 it does NOT reassign the target view; (3) the embedded-child template render is single-sourced (the #1646 cure) — the pure render core, including the security-hardened escape + DEBUG-gate error path (CWE-79/CWE-209), is extracted verbatim into module-level websocket.render_embedded_child_html, the WS _render_embedded_child is now a thin delegating shim, and the runtime calls the same helper (one implementation, no parallel copy to drift). No behavior change for current WS consumers_handle_event_inner routing is untouched (WS events still flow through it; event stays out of RUNTIME_OWNED_VERBS, the WS flip is Phase 2.3) — and SSE is a structural no-op for both checks (no components/sticky → falls through to the single-view path). New direct-runtime suite python/djust/tests/test_runtime_child_routing_1892.py drives runtime.dispatch_event against a MockTransport with a real parent LiveView + sticky child + LiveComponent (TestRuntimeStickyChildRouting, TestRuntimeComponentRouting, TestRuntimeEmbeddedRender); each security-critical guard (component-handler validation, view_id log-sanitization, embedded-error escape) has a reproduce-first + gate-off pair (#1468), all three verified to go RED when the guard is removed. The existing WS child-routing suites (test_sticky_child_event_noop_1802, test_sticky_child_recovery_1813, test_waiter_component_propagation, test_time_travel_flow) stay green — WS path unchanged.

  • The runtime event spine grew toward WebSocket parity — ref echo, source/event_name, _force_full_html, _notify_waiters, and the #700 push-only skip (#1889, ADR-022 Iter 2 Phase 2.0). First PR of the 4-phase WS-event convergence split. ViewRuntime._dispatch_event_inner / _render_and_send (the minimal SSE event spine, SSE's only event path post-Iter-1) gained the transport-agnostic shared behaviors the bespoke WS _handle_event_inner has but the runtime lacked: (1) the client ref (#560) is now echoed back on BOTH the noop and every update frame, coerced to int (type-confusion guard); (2) the noop frame carries source="event" + event_name and the update frames carry source="event" for the client's #560 response-sequencing; (3) a handler that sets _force_full_html now defeats the auto-skip and sends a full html_update (patches discarded, flag consumed), mirroring websocket.py:4039-4040; (4) _notify_waiters (ADR-002 Phase 1b) runs after the handler so wait_for_event futures resolve on the SSE path too; (5) the #700 identity push-only auto-skip (the id()-identity variant beyond the assigns-snapshot skip) is ported, so a push-events-only handler emits a noop instead of a wasted re-render. No behavior change for current WS consumerswebsocket.py is untouched (WS events still use _handle_event_inner; event stays out of RUNTIME_OWNED_VERBS, the WS flip is Phase 2.3) — and SSE consumers gain the #560 ref/source fields. Each grow is reproduce-first + gate-off verified (#1468): new behavioral pins (TestEventSpineRefEcho, TestEventSpineForceFullHtml, TestEventSpineNotifyWaiters, TestEventSpineIdentityPushSkip) and a source-enumeration net (TestEventSpineEnumeration) in python/djust/tests/test_transport_behavioral_parity.py so a future drop re-forks RED; a real-SSE-transport end-to-end suite (TestSSEEventSpineParity in python/djust/tests/test_sse_runtime_convergence_1887.py, driving the /message/ endpoint which forwards the full ref-carrying envelope); and an extended RUNTIME_OWNED_VERBS contract pin (TestRuntimeOwnedVerbsContract::test_event_spine_grown_but_event_not_yet_ws_owned in python/djust/tests/test_ws_receive_runtime_dispatch_1852.py) pinning the Phase-2.0 ↔ 2.3 boundary.

  • The SSE transport's mount + event now route through the shared ViewRuntime, retiring the legacy bespoke SSE copies (#1887, ADR-022 Iter 1). The SSE GET-stream mount and the legacy /event/ POST previously had their own hand-written mount/event/render/async helpers (_sse_mount_view, _sse_handle_event, _sse_handle_event_inner, _sse_run_async_work) — a fork of the same dispatch logic the WebSocket and ViewRuntime paths carry, i.e. a live instance of the #1646 parallel-path-drift class. Both now dispatch through session.runtime.dispatch_mount / dispatch_event — the SAME spine the SSE /message/ endpoint and the WS url_change shim already use — and the legacy helpers (plus their orphaned flush/async/cache sub-helpers) are deleted. No behavior change for SSE consumers: mount still renders against the real authenticated request, events still stream patch/html_update frames, object-permission denial still blocks the mount (now via dispatch_mount's Iter-0 check), and start_async/@background work still streams its result (the runtime grew the async dispatcher SSE needs — this also fixes a latent legacy-SSE drop of start_async named-task work, since the legacy path only dispatched the never-set _async_pending format). SSE-specific behavior is preserved via two new SSESessionTransport hooks: build_request() (the runtime mounts against the real HTTP request, not a synthesized userless one) and on_view_mounted() (stamps _sse_session_id / _sse_session / session.view_instance). No websocket.py changes (WS convergence is Iter 2/3). New end-to-end integration suite python/djust/tests/test_sse_runtime_convergence_1887.py (mount / event / object-perm / start_async via the real endpoints, with gate-off witnesses, #1468); existing SSE + mount-chokepoint + has_ids-parity tests migrated to the converged path.

Fixed

  • An inline <script> (or <style>) inside the dj-root is no longer silently neutered by whitespace collapse, so its page JS actually runs on mount (#1927; the live-morph twin of #1848/#1871). TemplateMixin._strip_comments_and_whitespace — the single normalizer every render path runs (HTTP GET, WS mount, SSE/runtime, streaming) to match the Rust VDOM parser's whitespace pass — preserved whitespace only for <pre>/<code>/<textarea>, but the Rust parser ALSO preserves <script>/<style> (crates/djust_vdom/src/parser.rs:475). So the re.sub(r"\s+", " ") pass collapsed every newline inside an inline <script> onto ONE line; a leading // line comment then commented out the entire body, so the script's addEventListener / init never ran — with NO console error. This is why #1871's window.djust._runInsertedScripts mount-morph re-execution could not cure the symptom: the script was already neutered at render, before any morph re-execution. The fix adds <script>/<style> to the preserved-block set (the #1646 parallel-path-drift cure: the Python normalizer now matches the Rust parser's preserve set exactly), and — CRITICAL ORDERING — extracts the raw-text <script>/<style> blocks BEFORE the HTML-comment strip so an HTML-comment-looking token inside a JS/CSS body (var s = '<!-- x -->') is not mistaken for markup and stripped. Non-script/style whitespace collapse is unchanged. Diagnosed by driving the demo /demos/browser-smoke/ page in a real browser (the inline tab-toggle script's __smokeTabsWired stayed undefined until this fix); validated end-to-end in-browser (both the HTTP-GET parse AND the #1610 WS-mount morph now run the script, tab toggle works, no console error). New cases in TestStripCommentsAndWhitespace (python/djust/tests/test_strip_whitespace.py): the exact #1927 //-comment-led-body trigger, multi-script/style preservation, the comment-inside-script ordering guard, and a "non-script whitespace still collapses" non-regression. Gate-off (#1468) verified: reverting the <script>/<style> preservation collapses the body to one line and reds the comment-not-swallowed assertion. The now-blocking browser-smoke CI job (this PR) is the end-to-end validator.

  • A batched object/permission-denied mount no longer closes the SHARED WebSocket socket, killing the sibling mounts (#1922, #291-consistency). WSConsumerTransport.finalize_mount_auth closed the socket with code 4403 UNCONDITIONALLY on the permission_denied verdict, while gating the redirect verdicts (login-required / on_mount redirect) on not mounting_in_batch (the #291/#1780 multiplexed-path rule). Inside a mount_batch the socket is SHARED across sibling mounts, so a single object-level- or permission-denied view dropped the shared socket and collaterally killed the survivor mounts (the #291 failure class; pre-existing parity with the old bespoke handle_mount which also closed unconditionally). The permission_denied close is now gated on not self.mounting_in_batch too, so all blocking mount-auth verdicts share one batch-aware close. No security loss: the denied view is NOT mounted regardless — the runtime sends the error (permission_denied) frame and clears view_instance BEFORE finalize_mount_auth runs; only the transport-level socket close is suppressed in the batch case, so the denied view simply reports in failed[] exactly as the redirect case already reports in navigate[]. The denial holds; the siblings (which the client IS authorized for) are no longer dropped. A SINGLE (non-batch) denied mount STILL closes 4403 (mounting_in_batch is False outside a batch). New cases in test_ws_auth_close_socket.py (real WebsocketCommunicator, mirroring the #291 batch harness): test_mount_batch_with_objperm_denied_view_does_not_close_shared_socket (denied view → failed[], public sibling mounts, shared socket pongs = open) and test_single_objperm_denied_mount_still_closes_socket (over-gating guard). Gate-off (#1468) verified: reinstating the unconditional permission_denied close makes the batched-denial test go RED (the ping openness probe receives websocket.close instead of pong); the redirect-verdict gate and the single-mount close are unchanged.

  • Post-mount-flip cleanup — the DEBUG event-render residuals THE FLIP scoped out are now folded onto the runtime path, and the dead _extract_* consumer copies are removed (#1908, #1921). Two post-convergence cleanups from the WS event/mount flips (#1907/#1919), both inert in PRODUCTION. (#1908) DEBUG residuals: the deleted bespoke _send_update attached three things a runtime-routed WS event (which sends via transport.send directly) dropped — (1) the per-event _debug debug-panel payload (_attach_debug_payload, DEBUG + _debug_panel_active gated) plus the top-level timing / performance fields (gated on _should_expose_timing() = DEBUG or DJUST_EXPOSE_TIMING); (2) the no_patches context_snapshot the bespoke path passed to _emit_full_html_update; and (3) the cosmetic _current_event_name / _current_event_ref consumer attrs. A new Transport.on_event_frame(view, frame, *, event_name, event_ref) hook (SSE no-op) — called by _render_and_send in-place just before every patch / html_update event frame — attaches (1) via the consumer's existing _attach_debug_payload + _should_expose_timing (verbatim bespoke gate; performance from the event_context-borrowed PerformanceTracker; timing.render from a render-duration measured per event) and stamps (3); on_render_emitted grew a context param so the no_patches branch threads get_context_data() back into the snapshot (2), re-captured only under DEBUG so PRODUCTION never double-calls it. PRODUCTION byte-identical: every attached field is DEBUG/timing-gated, so a prod-mode WS event frame is unchanged (both were also absent in prod on the bespoke path); the internal _timing_render_ms marker is always popped before send and never reaches the wire. (#1921) dead code: the LiveViewConsumer._extract_cache_config / _extract_optimistic_rules copies had ZERO callers after the mount flip deleted the handle_mount body that called them (orphan-grep confirmed across python/ + tests/); ViewRuntime owns the live copies the mount frame uses. Removed; the runtime docstrings' stale "Mirror of LiveViewConsumer._extract_*" refs are corrected. No change to RUNTIME_OWNED_VERBS / routing; SSE unaffected. New cases in TestResidualFoldObservability + TestDebugResidualOnEventFrame (python/djust/tests/test_ws_event_flip_parity_1896.py): real-WebsocketCommunicator DEBUG-vs-PRODUCTION parity (a DEBUG event frame carries _debug, timing under expose-timing; a prod frame carries NEITHER _debug/timing/performance nor the internal marker) + direct-hook unit pins for the context snapshot, the consumer-attr stamp, the panel-closed/best-effort gates, and the #1921 deletion. Gate-off (#1468) verified: gating the on_event_frame fold + the context threading off makes the 8 behavior-meaningful tests RED.

  • The SSE /event/ alias now forwards the client-sent ref so the #560 ref echo works on BOTH SSE endpoints (#1891). The /message/ endpoint forwards the raw body verbatim to runtime.dispatch_message, so a client-supplied top-level ref reached dispatch_event and was echoed on the noop / update frame (#560, ADR-022 Iter 2 Phase 2.0). The legacy /event/ alias instead REBUILT the dispatch dict as {type, event, params} and DROPPED ref — so the runtime's _dispatch_event_render (which reads ref from the top level of the data dict) saw None and echoed nothing, leaving the end-to-end ref echo exercised only via /message/. DjustSSEEventView.post now carries ref through into the dispatch frame ({type, event, params, ref}); the runtime coerces it to int / None, so no endpoint-side validation is needed. params already carried _cacheRequestId / component_id / view_id (SSE has neither component nor sticky-child routing), so ref was the only dropped field. New cases in TestSSEEventAliasRefEcho (python/djust/tests/test_sse_runtime_convergence_1887.py, real-SSE end-to-end: update + noop frames echo the ref over the /event/ alias) and TestDjustSSEEventViewPost::test_forwards_ref_to_dispatch_event (python/tests/test_sse.py, the dispatch-dict pin). Gate-off (#1468) verified: reverting the rebuild to the pre-fix {type, event, params} shape makes the two echo tests RED while the gate-off witness (which re-drops ref to confirm absence) stays green.

  • component_id-routed WebSocket events now re-render the parent and emit html_update instead of erroring (#1898, fixed by #1907 THE FLIP). The deleted bespoke _handle_event_inner component_id branch resolved + ran the LiveComponent handler but never re-rendered the parent view: html stayed None, the html_update fallback stripped None and raised TypeError, and handle_exception turned it into an error frame — so a working component event surfaced to the client as an error with no DOM update. Now that WS events route through ViewRuntime.dispatch_event, the runtime's _dispatch_component_event (the Phase-2.1 port) re-renders the parent (component VDOM is separate from the parent's), emits a parent-scoped html_update carrying the parent's updated state (e.g. values pushed up via send_parent), and echoes the event ref. The #1896 parity net's component_id test is updated errorhtml_update (the single intended behavioral change of the flip); its gate-off sibling (a bogus component_id still errors Component not found at resolution) stays green, proving the positive test genuinely resolves a real component.

  • ViewRuntime now drains all 8 flush queues like the WebSocket path, fixing flash/page-metadata/layout/a11y/i18n silently dropped on SPA navigation (#1885 / #1646, ADR-022 Iter 0). The runtime drained only 3 of WebSocket _flush_all_pending's 8 turn-end queues (push_events / navigation / deferred), so its one production user — url_change (dj-patch click / popstate SPA navigation) — silently dropped flash messages, page-metadata (title/meta) updates, set_layout swaps, accessibility announcements, and i18n commands queued during handle_params() (a live parallel-path-drift instance, #1646, INSIDE the convergence target). The runtime now has a single _flush_all_pending that drains all 8 queues in WebSocket's exact canonical order (mirrors websocket.py:888), called from both turn-end sites (event render + url_change) so a future queue addition cannot be wired on one path and not the other. New behavioral-parity nets (TestFlushQueueParity, TestWireVersionParity, TestWsOnlyBehaviorEnumeration in python/djust/tests/test_transport_behavioral_parity.py) AST-pin the WS↔runtime flush-queue set + order, the wire-version stamping (#1858), and the known WS-only mount/event behaviors so future ViewRuntime-convergence drift re-forks RED. Reproduce-first + gate-off (#1468) verified: removing the 5 added flush lines reproduces the pre-fix 3-of-8 state and the parity net detects exactly the missing {flash, page_metadata, pending_layout, accessibility, i18n}.

  • Systemic test-isolation: one autouse fixture resets djust's process-globals between tests, retiring the shared-global flaky class (#1883, #1882). Three shared-process-global test-pollution flakes in two milestones were all the SAME class — a process-global left dirty across tests in an xdist worker: #1862 (ROOT_URLCONF leak, PR #1874), #1875 (djust_hotreload channel-layer pollution, PR #1881), and #1882 (process-global wire-version drift — a stray djust_hotreload frame on the cached InMemoryChannelLayer re-renders on a later consumer and bumps its per-connection _next_version() counter, so test_time_travel_jump_recovery_version_is_current saw the jump land at version 4 instead of 3 under -n auto). Each was whack-a-moled per-test. The systemic cure is a new shared helper djust.test_isolation.reset_djust_globals() (DRY, #1646) called by an autouse _reset_djust_globals fixture in BOTH test roots (tests/conftest.py, mirroring cleanup_session_cache; and python/djust/tests/conftest.py) that resets djust's leak-prone process-globals BEFORE each test: the Channels layer manager (channel_layers.backends.clear() — the #1875/#1882 class), Django's URLconf caches (clear_url_caches() + set_urlconf(None) — the #1862 class), djust's route-map cache (_reset_route_map_cache()), and the module-level itertools.count id counters (mixins.sticky._view_id_counter, components.templatetags.djust_components._tooltip_id_counter). It is deliberately conservative (runs on every test): it resets ONLY state that genuinely leaks and is lazily re-derived, with lazy imports wrapped so a missing optional dep (Channels) never errors the fixture; it does NOT touch state_backend (already isolated by cleanup_session_cache), the keyed self-invalidating _jit_serializer_cache, the one-shot _CUSTOM_FILTERS_BRIDGED bootstrap, or per-instance StickyChildRegistry._child_views. The #1882 cure is proven deterministically + gate-off (#1468) in python/djust/tests/test_global_isolation_1883.py: a stale-layer sibling group_send reproduces the exact got 4 drift WITHOUT the reset and the clean 1 -> 2 -> 3 chain WITH it, plus per-global unit pins (neutering reset_djust_globals fails 5/8 cases). Verified with the 3-clean-runs gate (#1174): full suite -n auto × 3 (plus × 3 bonus) all clean, 8163 passed / 0 failed each run — the fixture breaks no existing test.

  • De-flaked the 17 #1721 theme-tag tests under -n auto — the systemic #1883 fixture now re-asserts the ready()-time Rust tag handlers (#1928, #1883-class). python/djust/tests/test_theme_tags_rust_engine_1721.py flaked under full -n auto: has_tag_handler("theme_panel") returned False and all 17 tests 500'd with Unsupported template tag '{% theme_panel %}'. Root cause is the same shared-process-global class as #1883: the process-global Rust tag-handler registry (crates/djust_templates/src/registry.rs) is shared across an xdist worker, and DjustThemingConfig.ready() / DjustComponentsConfig.ready() register the {% theme_X %} / {% render_slot %} handlers only ONCE per process. tests/benchmarks/test_tag_registry.py::TestRustPythonInterop clears the registry (clear_tag_handlers()) and its restore_registry fixture restores ONLY the djust.template_tags built-ins — not the app-registered theme/component handlers — so once it runs in a worker the theme handlers stay gone for every later test (also reproducible by any test that django.setup()s without djust.theming). This is the exact #1771 bug fixed only in tests/unit/test_tag_registry.py (parallel-path drift, #1646); the benchmark twin was uncovered. Systemic cure: reset_djust_globals() (python/djust/test_isolation.py) grows _reset_rust_tag_handlers(), which re-runs both ready()-time registrars BEFORE every test in both test roots — idempotent (theming guards on has_tag_handler, component overwrites) and a no-op without the Rust extension, so it is cheap. Retires the whole flaky class regardless of which polluter ran, rather than patching the one benchmark file. New cases in python/djust/tests/test_global_isolation_1883.py: test_reset_reasserts_theme_and_component_tag_handlers_1928 (clear → prove gone → reset → prove restored) + test_gate_off_clear_without_reset_loses_theme_handler_1928 (gate-off sibling proving the bare clear loses the handler, non-tautological per #1468). Reproduce-first verified: the benchmark-polluter-then-theme order failed 17/18 pre-fix and passes 18/18 post-fix; gate-off (#1468) verified (neutering _reset_rust_tag_handlers() re-reds both the repro order and the new pin). 3-clean-runs gate (#1174): full suite -n auto × 3 all clean (8604 passed / 0 failed each).

  • De-flaked test_mount_batch_with_login_view_does_not_close_shared_socket under -n auto (#1875). The #291 regression test (a login-redirecting view in a mount_batch must NOT close() the shared socket) was order-fragile under full -n auto saturation — it failed 1 of 3 full runs, passed in isolation. Two independent races, both fixed without weakening the guard: (1) the consumer joins the process-global djust_hotreload channel-layer group on connect, so a sibling test's group_send("djust_hotreload", ...) could deliver a stray frame into the test's receive_nothing window — now isolated by clearing the cached channel-layer backend so the consumer connects to a fresh, unpolluted InMemoryChannelLayer; (2) the receive_nothing(timeout=0.5) "no mid-batch close" check raced a wall-clock window (flaky under CPU saturation per the #1830/#1795 flaky-timing canon) — replaced with a deterministic pingpong openness probe (a closed socket cannot pong). Gate-off verified (#1468): removing the _mounting_in_batch close-suppression guard makes the test fail (Expected type 'websocket.send', but was 'websocket.close'). Verified with the 3-clean-runs gate (#1174): full suite -n auto × 3 all clean.

  • V004 no longer false-fires on framework-invoked lifecycle hooks (#1684). The V004 system check ("public method looks like an event handler but is missing @event_handler") flagged user overrides of hooks the framework calls directly (self.X() / getattr / hasattr) rather than through the user-event router — these must NOT carry @event_handler, but their names match the event-handler-like regex and were absent from the V004 lifecycle-skip set in checks/components.py. Canonical symptom: handle_presence_leave (bit djust-org/djust-start#5). Added the 8 framework-invoked hooks (handle_presence_join/handle_presence_leave/handle_cursor_move/handle_tick/handle_async_result/handle_component_event/handle_info/on_wizard_complete) to the skip set. The fix originally landed on the 1.1 branch (#1685) against the pre-#1822-split checks.py; it was never ported to main's split checks/ (so the false-positive was live through 1.0.8) — this lands it on main. New regression TestV004LifecycleMethods::test_v004_ignores_framework_invoked_hooks_1684 (gate-off verified, #1468).

Security

  • ViewRuntime.dispatch_mount gained the signed state-snapshot HMAC restore + emit WebSocket has — byte-identical caps — and it goes LIVE for the SSE mount path (#1913, ADR-022 Iter 3 Phase 3.1). The opt-in state-snapshot feature (enable_state_snapshot = True) restores a view's public state from a client-echoed payload on back-navigation in lieu of mount(); the payload is a server-signed TimestampSigner blob (CWE-345 → CWE-915) whose restore is the SECURITY BOUNDARY. The runtime mount path — which is the SSE mount path since Iter 1 (#1887) — previously had NO snapshot restore at all, so converging SSE onto it without porting the restore would either drop the feature for SSE or (worse, if added carelessly) open an unsigned-snapshot injection vector. dispatch_mount now ports the WS restore VERBATIM (websocket.py:2491-2587): the same unsign_snapshot(blob, slug=view_path, sid=session_key) HMAC binding (a snapshot signed for view A / session S1 / older than DJUST_STATE_SNAPSHOT_MAX_AGE does NOT restore), the same size cap (64 KB verified inner JSON), keyset cap (256 keys), dict-type cap, the DJUST_STATE_SNAPSHOT_ENABLED operator master-switch, and the _should_restore_snapshot(request) view-level veto. The session key for the sid binding is sourced from request.session and stamped on the view (_django_session_key) so the runtime/SSE path validates the SAME session binding the WS path does. The matching emit (sign_snapshot on the mount frame, websocket.py:2754-2792) is also ported, opt-in only. Gated enable_state_snapshot — default views never restore or emit (#1552); for SSE the restore is a no-op unless the view opts in AND a snapshot is present. WS UNTOUCHEDhandle_mount keeps its own copy until the Phase 3.3b flip; RUNTIME_OWNED_VERBS / WS routing / handle_mount_batch are unchanged (websocket.py has no diff). New suite python/djust/tests/test_runtime_mount_state_restore_1913.py — doc-claim-verbatim HMAC-caps TDD (#1046): a snapshot signed for a different view / a foreign session / past the TTL / forged-unsigned / tampered / oversized / over-keyset / vetoed does NOT restore via the runtime path (state stays at the mount() default), each with a gate-off sibling (#1468). Gate-off verified: skipping the slug cap in unsign_snapshot makes the cross-view restore wrongly succeed (RED); gating the runtime restore/emit/hook-redirect off makes the corresponding tests RED. The existing WS pins (test_state_snapshot_signing.py, test_ws_reconnect_state_1465.py) stay green.

  • ViewRuntime gained a transport.recheck_event_auth(view) hook for opt-in per-event auth re-check (reauth_on_event, #1777 threat-model T3), and it goes LIVE for SSE (#1905, ADR-022 Iter 2 Phase 2.3a). Auth runs once at mount and the mount-time principal is cached on the session, so a user who logs out / loses a permission mid-session would keep dispatching events on the open connection until they reconnect. The bespoke WS handle_event already re-checks per-event auth when LIVEVIEW_CONFIG['reauth_on_event'] is set + the view requires auth (websocket.py:3193-3222), but the runtime had no equivalent — so the SSE event path (converged onto the runtime since Iter 1, #1887) had NO mid-session deauth gate at all. New Transport.recheck_event_auth(view) -> bool (default-True = no re-check) wired into ViewRuntime._dispatch_event_inner at the SAME point WS does — after the view-mounted check, BEFORE the actor branch and the handler. WSConsumerTransport replays the WS bespoke logic verbatim (re-resolve the user from the scope session via channels.auth.get_user, reflect onto view.request.user, re-run check_view_auth_lightweight; on failure navigate to the login url + close(4403)). SSESessionTransport re-checks against the LIVE event-POST request (session._event_request, stamped by the /event/ + /message/ endpoints just before dispatch — the current POSTer's request.user, not the stale mount request) — covering the case owner-binding (Finding #24) cannot: a still-authenticated, still-owning POSTer whose permission was revoked mid-session — and on failure sends an auth-error frame + ends the stream. Both fail-safe (any error skips the re-check, never breaks an event) and gated on reauth_on_event + login_required/permission_required (default views pay nothing). #291 multiplexed-path care: the runtime clears view_instance UNCONDITIONALLY on a False return (the state change that closes the security gap — no later frame on the session dispatches against the deauthorized view); the transport-terminating close is OWNED + gated by the hook (events are not batched today — mount_batch is mount-only — but the close stays gateable if events are ever collected, matching the WS bespoke view_instance = None after close). LIVE for SSE; DORMANT for WS — WS events still run on the bespoke _handle_event_inner (which keeps its own inline re-check) until the Phase 2.3b flip; RUNTIME_OWNED_VERBS / WS routing are UNTOUCHED, websocket.py's reauth block is unchanged. New suite python/djust/tests/test_runtime_reauth_async_1905.py (TestSSEReauthOnEvent, TestReauthHookShape291, TestWSReauthAdapterPort): real-SSE end-to-end (mount with a permission, POST with it revoked → refused + error frame + stream end + view_instance cleared; still-authorized → renders; default-OFF → no re-check) + the #291 shape (state cleared even when the close is gated, via a fake transport) + the WS-adapter port. Reproduce-first + gate-off (#1468) verified: gating the recheck off makes the deauthorized SSE event wrongly render (RED) and the #291 state-clear assertion fail (RED). test_event_reauth_1777 (the bespoke WS path) stays green.

  • Closed a latent object-permission gap (IDOR-class) in ViewRuntime.dispatch_mount before it could go live (#1885, ADR-022 Iter 0). The WebSocket handle_mount enforces the ADR-017 post-mount object-permission check (check_object_permission), but ViewRuntime.dispatch_mount did not — so a view whose has_object_permission() returns False (or whose get_object() denies) would have mounted, rendered, and sent the denied object to the client through the runtime path. The gap was not yet exploitable (dispatch_mount has zero production call sites today), but Iter 1 of the ViewRuntime convergence (routing SSE through the runtime) would have made it live. The runtime mount now routes through the SAME shared enforce_object_permission chokepoint the other transports use (runtime.py, mirroring websocket.py:2554-2573), placed AFTER mount() (so get_object() can read URL-derived attrs) and BEFORE handle_params + render (so a denied object is never rendered or sent). Fail-closed; a no-op for views without a custom get_object (behavior-preserving). Reproduce-first + gate-off (#1468) verified: a denied view mounts + leaks its rendered HTML before the fix, emits only a permission_denied error frame after. New cases in TestDispatchMountObjectPermission (python/djust/tests/test_transport_behavioral_parity.py).

All releases · Atom feed