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 fordjust-mobile-togaconsumers 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_templatewires the variant resolver (LVN-II PR-4; ADR-019; #1578). Per-rendererresolve_template(base)delegates totemplate_resolver.resolve_variantusing the instance'soutput_format. TheNotImplementedErrorfromrender_with_diffnow 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 realPatchstreams 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.pywithvariant_name(base, output_format)andresolve_variant(base, output_format). Convention:foo.html→foo.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 intoNativeRenderer.render_with_diff. 6 new tests.NativeRendererscaffold +swiftui/composeregistry entries (LVN-II PR-2; ADR-019; #1578). Newpython/djust/renderers/native.pyintroducesNativeRendererwithSwiftUIRenderer/ComposeRenderersubclasses. Scaffold conforms to theRendererProtocol (output_formatper platform) but raisesNotImplementedErrorfromrender_with_diff— the actual widget-tree walker ships in LVN-II PR-3.RENDERERSregistry now resolves?platform=swiftuitoSwiftUIRendererand?platform=composetoComposeRenderer; this routes native handshakes to a defined error today rather than silent HTML fallback (which would mask client-side misconfigs). 9 new tests intest_native_renderer_scaffold.py. Existing handshake test intest_renderer_handshake.pyupdated to reflect thatswiftuiis now registered.Native widget vocabulary frozen at v1 (LVN-II PR-1; ADR-019; #1578). New
python/djust/renderers/widgets.pyexposesWIDGET_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), andis_widget_tag(tag). Newdocs/native-widget-vocabulary.mdis 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 byNativeRenderer(LVN-II PR-2) and mirrored indjust-native-ios/djust-native-android(LVN-III #1579 / LVN-IV #1580). 8 new pinning tests.LiveViewConsumerhandshake selects renderer via?platform=(LVN-I PR-3; ADR-019; #1577). Completes the LVN-I track (Protocol + ViewRuntime field + handshake wiring). NewRENDERERSregistry inpython/djust/renderers/__init__.pymaps"html"→HtmlRenderer;get_renderer_factory(platform)resolves a factory by?platform=value (returnsNonefor missing/unknown, so a typo never breaks a session — falls through to HtmlRenderer default at dispatch).LiveViewConsumer._get_runtimeparses?platform=fromscope["query_string"](ASGI bytes), resolves via the registry, and passesrenderer_factorytoViewRuntimeconstruction. Browser today sends no?platform=→ factory isNone→ byte-identical render. LVN-II (#1578) will registerswiftui+composeand 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 arenderer_factorykwarg (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. DefaultNonefor full back-compat — existing call sites inpython/djust/websocket.py:4248andpython/djust/sse.py:110are unchanged. The dispatch site (TemplateMixin.render_with_diff:942) still constructsHtmlRenderer(self)inline; PR-3 will route through the runtime's factory. New test filepython/djust/tests/test_runtime_renderer_param.py(3 cases: default-None, factory-stored, existing-callers-unchanged).djust.rendererspackage — pluggableRendererProtocol + defaultHtmlRenderer(LVN-I PR-1; ADR-019; #1577). Foundation iteration of the LiveView Native track. Introduces a structurally narrowRendererProtocol (@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).HtmlRendererwraps the existing Django-template + Rust VDOM pipeline; behavior is byte-identical to the pre-refactor inline call.TemplateMixin.render_with_diffatpython/djust/mixins/template.py:942now dispatches throughHtmlRenderer(self).render_with_diff(...)instead of inliningself._rust_view.render_with_diff(). What's explicitly NOT in this PR:ViewRuntimeplumbing (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,HtmlRendererconformance via@runtime_checkableisinstance, delegation to_rust_view, and the dispatch gate that the mixin routes throughHtmlRendererinstead 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-
WebsocketCommunicatorflip 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.pycharacterizes the six mount behaviors the flip must preserve, driving each against the CURRENT bespokehandle_mountover a real channelsWebsocketCommunicator(each passes now + must stay green through the flip = the parity proof, #1466/#1780/#1468): actor MOUNT (ause_actorsview renders an actor-backed mount frame, NOT the SSE refusal — Finding D),sticky_hold-before-mount-frame ORDERING vialive_redirect_mount(Finding B), Channelsgroup_addserver-push reachability (a broadcast to the mounted view's group reaches the session), periodic tick started at mount (asource="tick"frame arrives with no client event),optimistic_rules+upload_configson 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 nullsself.view_instancebefore re-mounting, and a naive flip that forgets to also resetruntime.view_instancewould silently no-op the re-mount sincedispatch_mountearly-returns whenview_instance is not None). Each asserts intermediate state + has a gate-off/contrast sibling.python/djust/tests/test_transport_behavioral_parity.pygrows 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_pendingsource pin) and extends_WS_ONLY_MARKERSwith 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"}) andhandle_mount/handle_mount_batchare 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 toTrue(it was opt-in /Falsethrough 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. Nativedj-navigateis now djust's canonical SPA-navigation model (no manuallive_session+ route-map wiring needed). Opt out withLIVEVIEW_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-blockingplaywright-testsleg into its OWN dedicatedbrowser-smokeCI job (nocontinue-on-error) and wired into thetest-summaryaggregate gate's AND-condition, so a re-introduced runtime break of either class now red-bars the PR (mirrors thedemo-checksblocking-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-blockingplaywright-testsleg — 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 viawindow.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"inRUNTIME_OWNED_VERBS, soreceive()routes every WS mount frame through the singledispatch_message→dispatch_mountchokepoint, and the ~870-line bespokehandle_mountbody is DELETED — reduced to a THIN SHIM overdispatch_mount(mirroring the event flip #1907 andhandle_url_change). Phases 3.0-3.3a had already growndispatch_mountinto a functional superset (F22 view resolver,run_pre_mount_authpre-mount auth+tenant via_check_auth,on_mounthooks, session + signed-snapshot state restore, post-mount object-permission,handle_params, actor mount, no-arm mount wire version, thesticky_holdpre-mount frame, the auth verdict→close finalize, the 2-queue mount-time drain) viaWSConsumerTransporthooks. This PR is the atomic flip with the three load-bearing findings wired: (A) idempotency — the shim, the_dispatch_runtime_ownedmount arm,disconnect, and thelive_redirectteardown all nullruntime.view_instanceBEFORE dispatch, so a reconnect /live_redirectre-mount is never silently no-op'd bydispatch_mount'sif view_instance is not Noneearly-return (the #560-class landmine); (B) ownership inverts — mount CREATES the view (runtime→consumer), so the shim reads backself.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_notifygroup_add, the periodictick_intervaltask, theuse_actorsflag, the real-scope_websocket_path/_websocket_query_stringstamps, the_sticky_auto_reattachedreset) is folded into the now-LIVE WSon_view_mountedtransport hook — madeasynctoawaitgroup_add— Finding B residual; (C) mount wire version via thenext_mount_versionhook (the no-arm consumer counter). The object-perm denial now closes the socket viafinalize_mount_auth(Finding E — the bespoke unconditionalclose(4403)had no runtime equivalent). Amount_batchbug the flip surfaced is also fixed:ViewRuntime._instantiate_viewfire-and-forgot its error frame viaasyncio.ensure_future, leaking a FAILED view's error into the NEXT survivor's collector (flipping a survivor tofailed[]); it now stashes the frame anddispatch_mountawait-sends it inside the correct_mount_onewindow.handle_mount_batch/_mount_onestay WS-only (the collector contract is unchanged;finalize_mount_authstill gates the redirect-verdict close onnot _mounting_in_batchper #291/#1780). Boundary pins updated to the post-flip reality: theRUNTIME_OWNED_VERBScontract, the Concern-4 mount-orchestration count-canary (run_pre_mount_auth/ object-perm /validated_host_from_scopeconverged ontoruntime.py),_WS_ONLY_MARKERS(group_add/channel_layer/tick_intervalmoved to the runtime hook), and thehandle_mountsource-grep pins (snapshot sign/unsign, skip-html,_ensure_tenant-before-restore,has_ids, mount-url validation, next-version) moved todispatch_mount; the fake consumers intest_sw_advanced.py/test_sw_advanced_flow.pygained a permissive_rate_limiterso they drive the runtime path. Gate-off-verified (#1468): neutering the Finding-A null makes thelive_redirectre-mount net (test_ws_mount_flip_parity_1911.py::TestLiveRedirectRemountIdempotency) RED; neutering theon_view_mountedfold makes thegroup_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 WShandle_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_VERBSis UNCHANGED ({"url_change", "event"}),handle_mount/handle_mount_batchare UNTOUCHED (websocket.pyhas no diff) — but the dormant hooks are now called bydispatch_mountat their WS-faithful positions (read offhandle_mount): (1)on_view_instantiated(view)right after instantiation (WS stamps_ws_consumer/_push_events_flush_callback/ observabilityregister_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 WSuse_actorsview now RENDERS through the actor system at the render step (verbatimhandle_mountordering — after auth +mount()+handle_params, html sent without strip/extract,websocket.py:2691-2706); SSE keeps refusing (uses_actors_for_mount→ False, so the structureduse_actors is not supported over SSEenvelope 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 (WSconsumer._next_version()— establishes the baseline, does NOT armrequest_htmlrecovery so_recovery_htmlstaysNone; SSE returns the raw Rustrender_with_diff()version, IMPLEMENTED here — the 3.2 SSE placeholder raised). The signature is widened to(html, rust_version=1)mirroringnext_client_versionso 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 ARMINGnext_client_versionthe event path uses. (4)on_mount_render_ready(view, html)(Finding B residual) runs after render, before the mount frame (WS sticky preservation + thesticky_holdframe emitted BEFORE the mount frame; SSE returnshtmlunchanged). (5)finalize_mount_auth(view, verdict)(Finding E) on the three auth-block verdicts (_check_authpermission_denied + redirect;dispatch_mountrun_on_mount_hooksredirect) — the runtime already sent the verdict frame + clearedview_instance, so the hook adds ONLY the transport-levelclose(4403)(WS unconditional for permission-denial, gated onnot _mounting_in_batchfor 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_mountis now a clean superset (Findings A/B prep) — the idempotency guard +view_instanceownership are untouched; the residual delta for the 3.3b flip is the routing flip + the A/B shim (theruntime.view_instancereset + read-back) only. New cases inTestRuntimeBasicMountParity,TestRuntimeActorMountParity,TestRuntimeNoArmVersionWiring,TestRuntimeAuthBlockFinalize,TestRuntimeStateRestoreParity(python/djust/tests/test_runtime_mount_parity_1917.py) — THE key 3.3a gate: drivesdispatch_mountover a REALWSConsumerTransport(direct-call shim, NOT viaRUNTIME_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, thenext_mount_versionwiring off → the wrong version is stamped. The Phase-3.2 DORMANT pins inpython/djust/tests/test_transport_mount_hooks_1915.pyare INVERTED to load-bearing WIRED pins (each hook is now referenced indispatch_mount/ the auth helper; SSEnext_mount_versionreturnsrust_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 theTransportprotocol (behavior-preserving no-op / refuse defaults),WSConsumerTransport(the real WS impl, each encapsulating the verbatim bespokehandle_mountlogic for its cited site), andSSESessionTransport(no-op / raw / refuse), addressing ADR-022 Iter 3 Findings B/C/D/E: (1)on_view_instantiated(view)— WS stampsview._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 (thedispatch_mountrefusal stays). (3)next_mount_version(html)— WS returnsconsumer._next_version(), the NO-ARM counterhandle_mountuses (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 fromnext_client_version, which arms for render-SEND frames), so_recovery_htmlstaysNoneafter 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_idssurvivor scan +_register_childre-registration) + thesticky_holdframe emitted BEFORE the mount frame (websocket.py:2080-2082/2836-2903), returninghtmlunchanged; SSE: returnshtmlunchanged (Finding B residual). (5)finalize_mount_auth(view, verdict)— WS: the transport-level socketclose(4403)the bespoke auth-finalization performs (websocket.py:2337-2401), GATED onnot consumer._mounting_in_batchfor 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_mountdoes NOT call any of these yet (Phase 3.3a wires them in) and the WS bespokehandle_mount/handle_mount_batchkeep doing all of this inline (untouched until the Phase 3.3b flip);RUNTIME_OWNED_VERBS/ WS routing are UNTOUCHED;websocket.pyhas no production diff. New cases inpython/djust/tests/test_transport_mount_hooks_1915.py(Test...MockTransport unit tests per hook + real-WebsocketCommunicatortests exercising the WS impls in isolation against a genuinely-mounted consumer —uses_actors_for_mountTrue for ause_actorsview,next_mount_versionreturns the consumer counter WITHOUT arming recovery,finalize_mount_authdoes NOT close when_mounting_in_batch=True) + DORMANT pins (dispatch_mountdoesn't reference the hooks, still stamps the raw Rust version + still refuses actor mounts;handle_mountstill does the work inline). All gate-off-verified (#1468): arming recovery innext_mount_versionreds the 3 no-arm tests, removing thenot _mounting_in_batchgate reds the in-batch tests, no-op'ingon_view_instantiatedreds the stamp test. The anti-drift_WS_ONLY_MARKERSpin (test_transport_behavioral_parity.py) dropscreate_session_actor/_find_sticky_slot_ids/register_view(no longer WS-only — the dormant WS hooks now reference them inruntime.py), mirroring the Phase-3.1state_snapshot_signedmove.ViewRuntime.dispatch_mountgrew the transport-agnostic mount STATE-RESTORE +on_mounthooks WebSockethandle_mounthas, 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 onenable_state_snapshot(#1552) so default views are unaffected: (1)run_on_mount_hooks(websocket.py:2383-2401) runs the registeredon_mounthooks after the pre-mount auth sequence + beforemount(); a hook that returns a redirect URL emits anavigateframe, clears the unmounted view, and aborts — transport-agnostically (no socketclose(); that belongs to the Phase 3.2/3.3afinalize_mount_authhook, 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 ofmount(). (3) Thehas_prerendered→skip_html_for_resumeresume optimization Phase 3.0 wired (but left dormant) now ACTIVATES: a restore (session or signed-snapshot) sets the new_mounted_from_restoreframework flag, so a resuming client that already holds the DOM skips the redundant mount-HTML swap (theversionstill flows so patches stay in sync)._mounted_from_restoreis initialized inLiveView.__init__BEFORE the_framework_attrssnapshot (#1393) so it is reset on reconnect and never persisted. Blast radius: SSE mount (which usesdispatch_mount) + the runtime;websocket.pyhas no diff,RUNTIME_OWNED_VERBS/handle_mount/handle_mount_batchare unchanged. The anti-drift_WS_ONLY_MARKERSpin dropsstate_snapshot_signed(no longer WS-only — now on the runtime too) and thelive_view.pysetattr-whitelist line numbers shift +11. New cases inpython/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); anon_mountredirect emits anavigateframe + aborts (RED when the redirect handling is gated off).ViewRuntime.dispatch_mountgrew the transport-agnostic mount behaviors WebSockethandle_mounthas, 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 ofhandle_mountover 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_kwargsstash (#1895,websocket.py:2596, placed aftermount()+ object-perm, beforehandle_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_baselinepost-mount (websocket.py:2598-2603); (3)has_prerendered→skip_html_for_resumemachinery (websocket.py:2804-2816), dormant until Phase 3.1 wires session-restore (the_mounted_from_restoreflag defaultsFalse, so HTML is always sent today); (4)optimistic_rules(DEP-002) +upload_configson the mount frame (websocket.py:2823-2834, via a new runtime_extract_optimistic_rulesmirror); (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_pendingthe 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 intest_handle_mount_drains_queues.py. Blast radius: SSE mount (which usesdispatch_mount) + the runtime;websocket.pyhas no diff andRUNTIME_OWNED_VERBSis unchanged. Every grow has a gate-off witness intest_transport_behavioral_parity.py(7/7 verified RED). New cases inTestMountStashAndBaselines,TestMountAsyncAndPushDrain,TestMountFrameOptimisticAndUpload,TestMountFrameWireVersion.THE FLIP: every WebSocket event now routes through
ViewRuntime.dispatch_event— the bespoke_handle_event_inneris 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 toRUNTIME_OWNED_VERBS(now{"url_change", "event"}), soreceive()routes every WS event through the singleViewRuntime.dispatch_messagechokepoint;handle_eventbecomes a thin shim overruntime.dispatch_event(mirroringhandle_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 newTransporthooks (SSE no-op):on_render_emittedcarries the production-visible DJE-053 warning (#1079 — it MUST survive, and does) plus the_emit_full_html_updatesignal on the no-patch render branch, andon_handler_timingcarries therecord_handler_timingpercentile telemetry;cache_request_idwas 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_navigationis nowawait-ed (was fire-and-forget) and (2) the skip-render branch now calls_flush_all_pending, so alive_redirect()/ navigation command queued by a state-unchanging handler still emits itsnavigationframe within the event turn (WS parity); and (3) the runtime's_dispatch_event_rendernow records a time-travel snapshot witherror="permission_denied"/"validation_failed"on the security-rejected + validation-rejected early-return paths (record_event_startmoved BEFORE the security check) — the bespoke_handle_event_innerrecorded these for the debug panel, and the first flip pass dropped them for non-actor views (caught bytests/integration/test_time_travel_flow.py::test_permission_denied_view_handler_records_with_error). Boundary pins updated (RUNTIME_OWNED_VERBScontract, 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. NewTestResidualFoldObservability(DJE-053 +record_handler_timingsurvival, with reason/version gate-off siblings) and aWebsocketCommunicatorregression forstart_async/@backgroundstreaming itssource="async"result over the runtime async path. Gate-off (#1468): removing"event"fromRUNTIME_OWNED_VERBSmakes all 11test_ws_event_flip_parity_1896behaviors fail withUnknown message type: event(the bespokeelifis 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.ViewRuntimeasync-result frames now carrysource="async", reconciling them with the WebSocket_run_async_workframes; and the deaduse_binaryframing 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) asyncsource="async"reconcile —ViewRuntime._render_async_result(thestart_async/@backgroundcompletion render shared by the success + error paths) emittedpatch/html_updateframes with NOsourcetag, while the WS_run_async_worktags all four of its framessource="async"(websocket.py:1166/1186/1223/1238). The client usessourceto distinguish an out-of-band background-completion update from the in-turnsource="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 stampsource="async". LIVE for SSE +url_changeasync work (both use the runtime async dispatcher today); WS picks it up post-flip (Phase 2.3b). (2) binary-framing confirm —consumer.use_binaryis dead: initialized toFalseatwebsocket.py:580('MessagePack support TODO') and never setTrueanywhere in the package; the only honoring site is_send_update's binary branch (websocket.py:1391), whichWSConsumerTransport.senddoes NOT traverse (it callsconsumer.send_json, always JSON). DESCOPED (no new binary path invented) + PINNED so a future enable is a deliberate, tested change: a guard test assertsWSConsumerTransport.sendemits JSON viasend_json(matching live WS), plus a source-grep pin that no production module assignsuse_binary = True. No change toRUNTIME_OWNED_VERBS/ WS routing; WS_handle_event_inner's async/binary paths stay on the bespoke handler until 2.3b;websocket.pyhas no diff. New cases inTestAsyncSourceReconcile/TestBinaryFramingConfirm(python/djust/tests/test_runtime_reauth_async_1905.py): real-SSE end-to-end (astart_asynccompletion frame carriessource="async") + unit (both branches tagged) + the JSON-emit + source-grep pins, with a gate-off witness (#1468) — removing thesource="async"tag makes the SSE end-to-end + unit assertions RED.test_async_integration+test_sse_runtime_convergence_1887stay green.ViewRuntimegained 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 lackeddj_activitydeferral 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_innerdoes (websocket.py:3254-3273gate +4290-4294flush). Two parts: (1) Gate —ViewRuntime._dispatch_event_render(after embedded-child routing, before security validation) replicates the WS gate VERBATIM, reusing the SAME transport-agnosticActivityMixinview 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-blindActivityMixin._flush_deferred_activity_eventsas the_dispatch_single_eventprovider, somixins/activity.pyis UNCHANGED (the flush already accepts any object exposing that method). The newViewRuntime._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-enteringevent_context— it already runs inside the borrowed context (which on WS holds the consumer_render_lock; re-acquiring the non-reentrantasyncio.Lockwould deadlock, thewebsocket.py:1467contract). A denied queued event is re-validated and dropped (WS flush per-event parity). Live behavior: this goes LIVE for SSE events — they route throughdispatch_eventsince Iter 1 (#1887), so SSE events now respectdj_activitydeferral (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_innergate/flush stays until Phase 2.3b;RUNTIME_OWNED_VERBS/ WS routing are UNTOUCHED;websocket.pyhas no diff. New suitepython/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 WSdj_activitybehavior (tests/unit/test_activity.py), the #1896 parity net (bespoke path, unchanged), andtest_sse_runtime_convergence_1887stay green.ViewRuntimegained an actor-event transport hook (transport.uses_actors()+transport.dispatch_actor_event()) so ause_actorsview'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_eventhad NO actor branch, while theuse_actorsguard lived ONLY indispatch_mount(which refuses SSE outright). A WS view mounts in actor mode (use_actors=True+ a createdactor_handle); once Phase 2.3b routes WS events through the runtime, such a view's events would have hitdispatch_eventwith no actor branch and silently run the handler IN-PROCESS via the normal render path, desyncing the actor's server-side diff baseline. Two newTransporthooks close the gap: (1)uses_actors(view)—WSConsumerTransportreturnsconsumer.use_actors and consumer.actor_handle is not None(the exact precondition of the bespoke WS actor block,websocket.py:3282),SSESessionTransportreturnsFalse(SSE has no bidirectional actor channel anddispatch_mountrefusesuse_actorsmounts,runtime.py:602); (2)dispatch_actor_event(view, event_name, params, *, event_ref, cache_request_id)—WSConsumerTransportruns the bespoke WS actor block (websocket.py:3282-3379) VERBATIM against the consumer (time-travel record/push in afinally, the shared_validate_event_security+validate_handler_paramschecks,actor_handle.event(), patch/HTML framing stamped with the consumer-owned wire versionconsumer._next_version()— the actor's internalresult['version']is IGNORED for the wire, #1788 — error handling, and the v0.7.0 deferred-activity flush),SSESessionTransportraisesNotImplementedError(never called —uses_actorsisFalse). Wired into_dispatch_event_innerBEFOREevent_context(the actor block holds no render lock, matching WS), gated onuses_actors(view)AND the event NOT being routed to a sticky child — the WSnot is_embedded_child_targetmutual exclusion (websocket.py:3280-3282); per #1467 acomponent_idevent does NOT reassign the target view and the WS actor block has no component handling, so acomponent_idevent on ause_actorsview goes through the actor (parity), and only aview_idresolving to a DIFFERENT child excludes it (_event_routes_to_sticky_childpeeks atview_idWITHOUT consuming it, so the non-actor sticky-child routing still pops it). Zero live-behavior change:uses_actorsisFalsefor 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_inneractor block are UNTOUCHED (they stay until 2.3b);websocket.pyhas no diff. New direct-runtime suitepython/djust/tests/test_transport_actor_event_1901.py(12 cases) builds aWSConsumerTransportover a fake consumer withuse_actors=True+ a fakeactor_handleand assertsdispatch_eventroutes todispatch_actor_event(the actor's.event()is called + the framed result is sent via_send_updatewith the consumer-owned wire version, NOT the in-process handler),uses_actorsFalse for SSE + a WS consumer withoutactor_handle, aview_id-routed event skips the actor while aview_id-equals-top event still routes to it, and the SSEdispatch_actor_eventraises; gate-off verified (#1468) — forcinguses_actorsto always returnFalsemakes 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 #1899event_contextsuite stay green.ViewRuntimenow BORROWS the consumer's render-lock + origin-channel + observability scope for each event via a newtransport.event_context()hook, and the dead runtime-local_render_lockis deleted (#1899, ADR-022 Iter 2 Phase 2.3a). Foundational fold thedj_activityre-dispatcher + the 2.3b WS-event flip sit on. Two load-bearing flip-scope findings drove this: (1)ViewRuntime._render_lockwas 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_notifyrender 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-CMtransport.event_context(view)on theTransportprotocol + both adapters lets the runtime borrow the consumer's EXISTING lock:WSConsumerTransport.event_contexton enter mirrors_handle_event_innerverbatim —await consumer._render_lock.acquire()(the existing object, not a new one),_processing_user_event = True, set the #1677 origin-channel contextvar toconsumer.channel_name, start aPerformanceTracker+ the SQLcapture_for_eventscope (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_contextis 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_inneris extracted into_dispatch_event_renderand run insideasync 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_innerare UNTOUCHED, anddispatch_url_change/_dispatch_url_change_innerare 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_changeis unaffected. New direct-runtime suitepython/djust/tests/test_transport_event_context_1899.pyasserts the WS context borrows the consumer's EXISTING lock object (held inside, released after — incl. on exception),_processing_user_eventTrue-inside/False-after, origin token set+reset, tracker current-inside/cleared-after; the SSE context is a no-op;ViewRuntimeno 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-opevent_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.
ViewRuntimenow records + persists per-event state the way the bespoke WS_handle_event_innerdoes, so the Phase 2.3 final flip (routing WS events through the runtime) persists identically: (1) time-travel record —record_event_start/record_event_endwrap 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 afinallyso a raising/permission-denied handler still appears in the debug panel; (2) session state-save #1466 —ViewRuntime._persist_state_after_eventmirrors the WS save (private attrs first, then publicget_context_data(), then components), gated on top-level-view identity ANDenable_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 150msasyncio.wait_for(#1475); (3) sticky-child state-save ADR-018 —ViewRuntime._persist_sticky_child_after_eventpersists aview_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 hookon_event_recorded(view, snapshot)replaces the WS_maybe_push_tt_eventdirect send:WSConsumerTransportdelegates to the consumer's existing_maybe_push_tt_event(single-sourcing the DEBUG-gatedtime_travel_eventframe),SSESessionTransportno-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 inwebsocket.pyis UNTOUCHED (the #1466/#1552 grep-pins intest_ws_reconnect_state_1465.py:119/313/320stay green;eventstays out ofRUNTIME_OWNED_VERBS, the WS flip is Phase 2.3). New direct-runtime suitepython/djust/tests/test_runtime_state_save_tt_1894.py(12 cases) drivesruntime.dispatch_eventagainst a MockTransport; each subsystem has a reproduce-first + gate-off pair (#1468) — removing theenable_state_snapshotgate makes a default view wrongly persist (RED), neutering the time-travel record drops the snapshot + hook (RED), and disabling the hook dispatch makes theon_event_recordedassertion 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_idLiveComponent,view_idsticky-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_innernow routes embedded children before the single-view path, mirroring the bespoke WS_handle_event_innersubsystems the runtime previously lacked entirely: (1) aview_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 scopedembedded_update {view_id, html, event_name}frame — the client-suppliedview_idis never echoed into the user-facing error (sanitize_for_login the structuredextraonly, verbatim from WS); (2) acomponent_id-targeted event resolves a child LiveComponent via_components, validates the handler against the COMPONENT (not the parent), notifies the PARENT's waiters withcomponent_idinjected (ADR-002), and emits a parent-scoped full-HTMLcomponent_eventframe — 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-levelwebsocket.render_embedded_child_html, the WS_render_embedded_childis 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_innerrouting is untouched (WS events still flow through it;eventstays out ofRUNTIME_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 suitepython/djust/tests/test_runtime_child_routing_1892.pydrivesruntime.dispatch_eventagainst 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 —
refecho,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_innerhas but the runtime lacked: (1) the clientref(#560) is now echoed back on BOTH the noop and every update frame, coerced to int (type-confusion guard); (2) the noop frame carriessource="event"+event_nameand the update frames carrysource="event"for the client's #560 response-sequencing; (3) a handler that sets_force_full_htmlnow defeats the auto-skip and sends a fullhtml_update(patches discarded, flag consumed), mirroringwebsocket.py:4039-4040; (4)_notify_waiters(ADR-002 Phase 1b) runs after the handler sowait_for_eventfutures resolve on the SSE path too; (5) the #700 identity push-only auto-skip (theid()-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 consumers —websocket.pyis untouched (WS events still use_handle_event_inner;eventstays out ofRUNTIME_OWNED_VERBS, the WS flip is Phase 2.3) — and SSE consumers gain the #560ref/sourcefields. Each grow is reproduce-first + gate-off verified (#1468): new behavioral pins (TestEventSpineRefEcho,TestEventSpineForceFullHtml,TestEventSpineNotifyWaiters,TestEventSpineIdentityPushSkip) and a source-enumeration net (TestEventSpineEnumeration) inpython/djust/tests/test_transport_behavioral_parity.pyso a future drop re-forks RED; a real-SSE-transport end-to-end suite (TestSSEEventSpineParityinpython/djust/tests/test_sse_runtime_convergence_1887.py, driving the/message/endpoint which forwards the fullref-carrying envelope); and an extendedRUNTIME_OWNED_VERBScontract pin (TestRuntimeOwnedVerbsContract::test_event_spine_grown_but_event_not_yet_ws_ownedinpython/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 andViewRuntimepaths carry, i.e. a live instance of the #1646 parallel-path-drift class. Both now dispatch throughsession.runtime.dispatch_mount/dispatch_event— the SAME spine the SSE/message/endpoint and the WSurl_changeshim 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 streampatch/html_updateframes, object-permission denial still blocks the mount (now viadispatch_mount's Iter-0 check), andstart_async/@backgroundwork still streams its result (the runtime grew the async dispatcher SSE needs — this also fixes a latent legacy-SSE drop ofstart_asyncnamed-task work, since the legacy path only dispatched the never-set_async_pendingformat). SSE-specific behavior is preserved via two newSSESessionTransporthooks:build_request()(the runtime mounts against the real HTTP request, not a synthesized userless one) andon_view_mounted()(stamps_sse_session_id/_sse_session/session.view_instance). Nowebsocket.pychanges (WS convergence is Iter 2/3). New end-to-end integration suitepython/djust/tests/test_sse_runtime_convergence_1887.py(mount / event / object-perm /start_asyncvia 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 there.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'saddEventListener/ init never ran — with NO console error. This is why #1871'swindow.djust._runInsertedScriptsmount-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__smokeTabsWiredstayedundefineduntil 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 inTestStripCommentsAndWhitespace(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-blockingbrowser-smokeCI 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_authclosed the socket with code4403UNCONDITIONALLY on thepermission_deniedverdict, while gating the redirect verdicts (login-required /on_mountredirect) onnot mounting_in_batch(the #291/#1780 multiplexed-path rule). Inside amount_batchthe 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 bespokehandle_mountwhich also closed unconditionally). Thepermission_deniedclose is now gated onnot self.mounting_in_batchtoo, so all blocking mount-auth verdicts share one batch-aware close. No security loss: the denied view is NOT mounted regardless — the runtime sends theerror(permission_denied) frame and clearsview_instanceBEFOREfinalize_mount_authruns; only the transport-level socket close is suppressed in the batch case, so the denied view simply reports infailed[]exactly as the redirect case already reports innavigate[]. The denial holds; the siblings (which the client IS authorized for) are no longer dropped. A SINGLE (non-batch) denied mount STILL closes4403(mounting_in_batchisFalseoutside a batch). New cases intest_ws_auth_close_socket.py(realWebsocketCommunicator, 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) andtest_single_objperm_denied_mount_still_closes_socket(over-gating guard). Gate-off (#1468) verified: reinstating the unconditionalpermission_deniedclose makes the batched-denial test go RED (the ping openness probe receiveswebsocket.closeinstead ofpong); 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_updateattached three things a runtime-routed WS event (which sends viatransport.senddirectly) dropped — (1) the per-event_debugdebug-panel payload (_attach_debug_payload, DEBUG +_debug_panel_activegated) plus the top-leveltiming/performancefields (gated on_should_expose_timing()= DEBUG orDJUST_EXPOSE_TIMING); (2) theno_patchescontext_snapshotthe bespoke path passed to_emit_full_html_update; and (3) the cosmetic_current_event_name/_current_event_refconsumer attrs. A newTransport.on_event_frame(view, frame, *, event_name, event_ref)hook (SSE no-op) — called by_render_and_sendin-place just before everypatch/html_updateevent frame — attaches (1) via the consumer's existing_attach_debug_payload+_should_expose_timing(verbatim bespoke gate;performancefrom theevent_context-borrowedPerformanceTracker;timing.renderfrom a render-duration measured per event) and stamps (3);on_render_emittedgrew acontextparam so theno_patchesbranch threadsget_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_msmarker is always popped before send and never reaches the wire. (#1921) dead code: theLiveViewConsumer._extract_cache_config/_extract_optimistic_rulescopies had ZERO callers after the mount flip deleted thehandle_mountbody that called them (orphan-grep confirmed acrosspython/+tests/);ViewRuntimeowns the live copies the mount frame uses. Removed; the runtime docstrings' stale "Mirror ofLiveViewConsumer._extract_*" refs are corrected. No change toRUNTIME_OWNED_VERBS/ routing; SSE unaffected. New cases inTestResidualFoldObservability+TestDebugResidualOnEventFrame(python/djust/tests/test_ws_event_flip_parity_1896.py): real-WebsocketCommunicatorDEBUG-vs-PRODUCTION parity (a DEBUG event frame carries_debug,timingunder expose-timing; a prod frame carries NEITHER_debug/timing/performancenor 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 theon_event_framefold + the context threading off makes the 8 behavior-meaningful tests RED.The SSE
/event/alias now forwards the client-sentrefso the #560 ref echo works on BOTH SSE endpoints (#1891). The/message/endpoint forwards the raw body verbatim toruntime.dispatch_message, so a client-supplied top-levelrefreacheddispatch_eventand 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 DROPPEDref— so the runtime's_dispatch_event_render(which readsreffrom the top level of the data dict) sawNoneand echoed nothing, leaving the end-to-end ref echo exercised only via/message/.DjustSSEEventView.postnow carriesrefthrough into the dispatch frame ({type, event, params, ref}); the runtime coerces it to int / None, so no endpoint-side validation is needed.paramsalready carried_cacheRequestId/component_id/view_id(SSE has neither component nor sticky-child routing), sorefwas the only dropped field. New cases inTestSSEEventAliasRefEcho(python/djust/tests/test_sse_runtime_convergence_1887.py, real-SSE end-to-end: update + noop frames echo the ref over the/event/alias) andTestDjustSSEEventViewPost::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-dropsrefto confirm absence) stays green.component_id-routed WebSocket events now re-render the parent and emithtml_updateinstead of erroring (#1898, fixed by #1907 THE FLIP). The deleted bespoke_handle_event_innercomponent_idbranch resolved + ran the LiveComponent handler but never re-rendered the parent view:htmlstayedNone, the html_update fallback strippedNoneand raisedTypeError, andhandle_exceptionturned it into anerrorframe — so a working component event surfaced to the client as an error with no DOM update. Now that WS events route throughViewRuntime.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-scopedhtml_updatecarrying the parent's updated state (e.g. values pushed up viasend_parent), and echoes the eventref. The#1896parity net'scomponent_idtest is updatederror→html_update(the single intended behavioral change of the flip); its gate-off sibling (a boguscomponent_idstill errorsComponent not foundat 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_layoutswaps, accessibility announcements, and i18n commands queued duringhandle_params()(a live parallel-path-drift instance, #1646, INSIDE the convergence target). The runtime now has a single_flush_all_pendingthat drains all 8 queues in WebSocket's exact canonical order (mirrorswebsocket.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,TestWsOnlyBehaviorEnumerationinpython/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_URLCONFleak, PR #1874), #1875 (djust_hotreloadchannel-layer pollution, PR #1881), and #1882 (process-global wire-version drift — a straydjust_hotreloadframe on the cachedInMemoryChannelLayerre-renders on a later consumer and bumps its per-connection_next_version()counter, sotest_time_travel_jump_recovery_version_is_currentsaw 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 helperdjust.test_isolation.reset_djust_globals()(DRY, #1646) called by an autouse_reset_djust_globalsfixture in BOTH test roots (tests/conftest.py, mirroringcleanup_session_cache; andpython/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-levelitertools.countid 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 touchstate_backend(already isolated bycleanup_session_cache), the keyed self-invalidating_jit_serializer_cache, the one-shot_CUSTOM_FILTERS_BRIDGEDbootstrap, or per-instanceStickyChildRegistry._child_views. The #1882 cure is proven deterministically + gate-off (#1468) inpython/djust/tests/test_global_isolation_1883.py: a stale-layer siblinggroup_sendreproduces the exactgot 4drift WITHOUT the reset and the clean1 -> 2 -> 3chain WITH it, plus per-global unit pins (neuteringreset_djust_globalsfails 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
#1721theme-tag tests under-n auto— the systemic#1883fixture now re-asserts theready()-time Rust tag handlers (#1928, #1883-class).python/djust/tests/test_theme_tags_rust_engine_1721.pyflaked under full-n auto:has_tag_handler("theme_panel")returnedFalseand all 17 tests 500'd withUnsupported 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, andDjustThemingConfig.ready()/DjustComponentsConfig.ready()register the{% theme_X %}/{% render_slot %}handlers only ONCE per process.tests/benchmarks/test_tag_registry.py::TestRustPythonInteropclears the registry (clear_tag_handlers()) and itsrestore_registryfixture restores ONLY thedjust.template_tagsbuilt-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 thatdjango.setup()s withoutdjust.theming). This is the exact #1771 bug fixed only intests/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 bothready()-time registrars BEFORE every test in both test roots — idempotent (theming guards onhas_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 inpython/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_socketunder-n auto(#1875). The #291 regression test (a login-redirecting view in amount_batchmust NOTclose()the shared socket) was order-fragile under full-n autosaturation — 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-globaldjust_hotreloadchannel-layer group on connect, so a sibling test'sgroup_send("djust_hotreload", ...)could deliver a stray frame into the test'sreceive_nothingwindow — now isolated by clearing the cached channel-layer backend so the consumer connects to a fresh, unpollutedInMemoryChannelLayer; (2) thereceive_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 deterministicping→pongopenness probe (a closed socket cannot pong). Gate-off verified (#1468): removing the_mounting_in_batchclose-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.V004no longer false-fires on framework-invoked lifecycle hooks (#1684). TheV004system 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 theV004lifecycle-skip set inchecks/components.py. Canonical symptom:handle_presence_leave(bitdjust-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 the1.1branch (#1685) against the pre-#1822-splitchecks.py; it was never ported tomain's splitchecks/(so the false-positive was live through 1.0.8) — this lands it onmain. New regressionTestV004LifecycleMethods::test_v004_ignores_framework_invoked_hooks_1684(gate-off verified, #1468).
Security
ViewRuntime.dispatch_mountgained 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 ofmount(); the payload is a server-signedTimestampSignerblob (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_mountnow ports the WS restore VERBATIM (websocket.py:2491-2587): the sameunsign_snapshot(blob, slug=view_path, sid=session_key)HMAC binding (a snapshot signed for view A / session S1 / older thanDJUST_STATE_SNAPSHOT_MAX_AGEdoes NOT restore), the same size cap (64 KB verified inner JSON), keyset cap (256 keys), dict-type cap, theDJUST_STATE_SNAPSHOT_ENABLEDoperator master-switch, and the_should_restore_snapshot(request)view-level veto. The session key for thesidbinding is sourced fromrequest.sessionand 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_snapshoton the mount frame,websocket.py:2754-2792) is also ported, opt-in only. Gatedenable_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 UNTOUCHED —handle_mountkeeps its own copy until the Phase 3.3b flip;RUNTIME_OWNED_VERBS/ WS routing /handle_mount_batchare unchanged (websocket.pyhas no diff). New suitepython/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 themount()default), each with a gate-off sibling (#1468). Gate-off verified: skipping the slug cap inunsign_snapshotmakes 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.ViewRuntimegained atransport.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 WShandle_eventalready re-checks per-event auth whenLIVEVIEW_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. NewTransport.recheck_event_auth(view) -> bool(default-True = no re-check) wired intoViewRuntime._dispatch_event_innerat the SAME point WS does — after the view-mounted check, BEFORE the actor branch and the handler.WSConsumerTransportreplays the WS bespoke logic verbatim (re-resolve the user from the scope session viachannels.auth.get_user, reflect ontoview.request.user, re-runcheck_view_auth_lightweight; on failurenavigateto the login url +close(4403)).SSESessionTransportre-checks against the LIVE event-POST request (session._event_request, stamped by the/event/+/message/endpoints just before dispatch — the current POSTer'srequest.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 onreauth_on_event+login_required/permission_required(default views pay nothing). #291 multiplexed-path care: the runtime clearsview_instanceUNCONDITIONALLY on aFalsereturn (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_batchis mount-only — but the close stays gateable if events are ever collected, matching the WS bespokeview_instance = Noneafter 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 suitepython/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_instancecleared; 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_mountbefore it could go live (#1885, ADR-022 Iter 0). The WebSockethandle_mountenforces the ADR-017 post-mount object-permission check (check_object_permission), butViewRuntime.dispatch_mountdid not — so a view whosehas_object_permission()returnsFalse(or whoseget_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_mounthas 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 sharedenforce_object_permissionchokepoint the other transports use (runtime.py, mirroringwebsocket.py:2554-2573), placed AFTERmount()(soget_object()can read URL-derived attrs) and BEFOREhandle_params+ render (so a denied object is never rendered or sent). Fail-closed; a no-op for views without a customget_object(behavior-preserving). Reproduce-first + gate-off (#1468) verified: a denied view mounts + leaks its rendered HTML before the fix, emits only apermission_deniederror frame after. New cases inTestDispatchMountObjectPermission(python/djust/tests/test_transport_behavioral_parity.py).