Added
- New system check
djust.V012— warns when a sticky-child template declares its owndj-view(nested duplicate binding) (#1803). A sticky child is embedded via{% live_render "...Path" sticky=True %}, which makes the framework emit the wrapper element itself —<div dj-view dj-sticky-view="<id>" dj-sticky-root data-djust-embedded="<id>">(static/djust/src/45-child-view.js). If the child's own template root also carries adj-viewattribute, the rendered page ends up with a nested, duplicatedj-viewinside the wrapper — the child's client-side mount breaks and itsdj-click/dj-inputevents silently don't bind. This is a subtle footgun because normal page views requiredj-view="<path>"on their root to be browser-mountable, so authors (and code-generating agents) reasonably add it everywhere, including sticky children, where it's wrong. Until now the only safeguard was a comment inside one example template.djust.V012(LiveView/Vcategory, Warning) walksLiveViewsubclasses withsticky = True, resolves each one's template source, and scans for a<div ... dj-view ...>root tag — converting the silent footgun into amanage.py checkwarning. False-positive guards: onlysticky = Trueviews are inspected (normal page views, which legitimately declaredj-view, are never flagged); the scan uses an anchored<div ... dj-view ...>opening-tag regex, not a bare substring;{% comment %}/{# #}/<!-- -->regions are stripped first so adj-viewdocumented inside a comment (e.g. the demo'saudio_player.htmlwrapper-example comment) is ignored; internal djust classes are skipped unless their module is a test/example. Suppress withDJUST_CONFIG = {'suppress_checks': ['V012']}. Documented on the sticky LiveViews guide and the system-checks reference. New regression cases inTestV012StickyChildOwnDjView(tests/unit/test_checks_v012_sticky_own_dj_view.py) cover the positive trigger, the comment-only / non-sticky-page-view / correct-child silent cases, suppression, the #1459 empirical canary (a freshtype()-built sticky child), and the #1468 gate-off self-test. Empirically validated againstmanage.py checkon the demo project (0 false positives; fires on an injected real footgun).
Fixed
Flaky
test_total_wall_clock_is_max_not_summade deterministic (#1795). The parallel-lazy-render concurrency test asserted a wall-clock ratio (parallel < serial/2); under fullmake test -n autoCPU saturation the 3 concurrent thunks couldn't get dedicated cores and the speedup ratio drifted past 0.5, false-failing the releasemake test(observed parallel=88.1ms vs threshold=85.8ms at the 1.0.5rc5 cut; passed in isolation). It now proves concurrency via deterministic interval overlap — each thunk records its[start, end]and a concurrent render satisfiesmax(start) < min(end)(all thunks start before any finishes), immune to timing jitter because launching N coroutines takes microseconds regardless of load. A new gate-off sibling,TestParallelRender::test_overlap_proof_rejects_a_serial_loop, pins that a serial loop does NOT overlap, so the proof stays non-tautological. Test-only; no production change.html_recoveryno longer resets an embedded sticky child to itsmount()defaults — P0 data loss (#1813). On an HTTP-prerendered page embedding{% live_render "Child" sticky=True %}, after a user interacted with the sticky child a failed parent patch triggeredhtml_recovery, which reset the sticky child to mount state and discarded the interactions. Two compounding defects, fixed together: (b1) the structural cure —{% live_render sticky=True %}(python/djust/templatetags/live_tags.py) constructed a freshchild_cls()+mount()on every parent render (the two pre-existing escape hatches —_sticky_preservedauto-reattach and session-backedrestore_sticky_child_state— are inert in the default config, the latter gated behindenable_state_snapshot=True), so every parent re-render and every_recovery_htmlsnapshot rendered the child at mount defaults; now a live-instance-reuse hatch re-renders the parent's already-registered live child (_get_child_view(sticky_id)from theStickyChildRegistry) instead of mounting fresh, independent ofenable_state_snapshotand composing with (not bypassing) the existing hatches. The shared_render_sticky_child_htmlhelper keeps the fresh-mount and reuse paths byte-identical (parallel-path-drift guard, #1646). (b2)(ii) recovery freshness —handle_request_html(python/djust/websocket.py) now re-renders the parent fresh when it has live sticky children (the embedded-child event branch sends a scopedembedded_updateand deliberately does not re-arm recovery, so the cached_recovery_htmlwas stale); re-rendering at recovery time is correct + lowest-overhead (recovery is rare) and faithful only because (b1) makes the re-render reflect the live child — non-sticky pages keep the cached-replay path unchanged. (a) the client-side trigger — the #1610 prerenderskipMountHtmlmorph keysmorphChildrenbynode.id, but the sticky wrapper (<div dj-view dj-sticky-view dj-sticky-root data-djust-embedded=...>) has noidand only aligns positionally, so once a preceding sibling count diverges the serverdj-idwas never stamped onto the live wrapper and the first parent patch fell back to a positional path that broke on child drift;_stampEmbeddedWrapperDjIds()(static/djust/src/03-websocket.js, bundle rebuilt) now runs after the morph and copies the serverdj-idonto each live wrapper matched by its stabledata-djust-embeddedvalue (the45-child-view.jsselector). Regression coverage: 5 WebsocketCommunicator end-to-end cases inpython/djust/tests/test_sticky_child_recovery_1813.py(default config, gate-off verified for both b1 and b2) and 7 cases intests/js/ws-mount-prerender-divergence-1813-sticky-djid.test.js(gate-off verified for the dj-id stamp).A worktree
git pushnow runs the pre-push pytest suite against the worktree's Python source, not the main checkout's (#1810). #1796 fixed interpreter resolution from agit worktree, but the editablematurin developinstall binds Python imports to the main checkout via a plaindjust.pththat appends<main>/pythontosys.path— so agit pushfrom a linked worktree ran the pre-push suite against the main tree's source, silently passing/failing on code the worktree never changed (worktree pushes still needed--no-verify, leaving CI as the only correct gate). Root cause confirmed empirically: the.so-less worktree + plain.pth(not an__editable__meta-path finder) meansPYTHONPATH— which Python inserts before.pthprocessing — wins when it points at the worktree'spython/, while a bare worktree import resolves the main tree (a sentinel added to the worktree's__init__.pywas invisible without the prepend, visible with it). Fix: a newscripts/run-with-venv-python.sh --worktree-pythonpathmode emits the current worktree'spython/dir to prepend toPYTHONPATH(a no-op — empty output — in the main checkout or outside a git tree) and symlinks the matching compiled_rust.<cache_tag>-*.sofrom the main checkout into the worktree'spython/djust/soimport djust._rustkeeps resolving once the Python source is shadowed (the.sois gitignored, so the symlink never appears ingit status). The pre-pushpytesthook in.pre-commit-config.yamlnow prepends this path. Caveat (documented inCONTRIBUTING.md): this shadows only Python source — Rust (djust._rust) changes still needmaturin developrun against the worktree; CI remains authoritative. New cases inTestWorktreePythonpath(tests/test_run_with_venv_python.py): worktree path-emit, main-checkout/no-package no-op, the behavior-meaningful PYTHONPATH-shadow precedence test (with a gate-off proving the prepend is load-bearing — without it the main source wins, the exact #1810 bug), the.sosymlink, and an entry-line source-pin on the config wiring (gate-off: reverting the entry line fails it).System check
djust.T004no longer flagsdocument.addEventListenerfor djust events that are dispatched ondocument, and now honorssuppress_checks(#1809). T004 (document.addEventListener('djust:...')→ usewindow) had two defects. (1) False positive that broke correct code: it assumed alldjust:events dispatch onwindow, but djust dispatches a whole family ondocument—djust:navigate-start,djust:navigate-end,djust:hvr-applied,djust:layout-changed,djust:ws-reconnected,djust:time-travel-state,djust:time-travel-event(sourced from the client bundle'sdocument.dispatchEvent(new CustomEvent('djust:...'))sites instatic/djust/client.js/src/03-websocket.js/18-navigation.js/40-dj-layout.js). Listening for those ondocumentis correct, yet T004 flagged them and told the user to switch towindow, which would break the listener (it would never fire). (2) Unsuppressible: the emission loop never called_is_check_suppressed, soDJUST_CONFIG = {"suppress_checks": ["T004"]}was a no-op. Fix:_DOC_DJUST_EVENT_REnow captures the event name; a new module constant_DOC_DISPATCHED_DJUST_EVENTS(frozenset, cited to the client.js dispatch sites) is used to skip the document-dispatched family; and the emission loop is gated on_is_check_suppressed("djust.T004")(mirrorsT002/C013), so both the["T004"]and["djust.T004"]forms now silence it. Window-dispatched events (djust:push_event,djust:before-navigate,djust:error,djust:shell-swapped,djust:vdom-cache-applied,djust:upload:*) still warn — the legitimate purpose of T004 is preserved. New cases inTestT004DocumentDispatchedEvents(navigate-end + every document-dispatched event not flagged; window-dispatchedpush_eventstill flagged) andTestT004Suppress(fires without suppression; silenced via both short and qualified IDs) inpython/tests/test_checks.py. Gate-off verified: disabling the allowlist makes the document-event tests fail; disabling the suppress guard makes the suppress tests fail. Docs updated indocs/system-checks.mdanddocs/guides/error-codes.md.Embedded sticky-child (
{% live_render "...View" sticky=True %}) events now produce a patch instead of a barenoop(#1802). A sticky / embedded child widget'sdj-click(and other) events did nothing in the browser: the event routed to the child's handler correctly and the handler ran, but the consumer returned{"type": "noop"}— no patch/HTML was sent — so the child's DOM never updated. Sticky/app-shell widgets (a headline feature) were effectively render-only / non-interactive; the workaround was to move the handler + state onto the page view. Root cause (traced symptom-up against a realWebsocketCommunicator, confirming thenoop): the auto-skip-render block inLiveViewConsumer.handle_eventsnapshotted public assigns onself.view_instance(the PARENT) both before and after the handler. Embedded-child events route viaview_idsotarget_viewis the CHILD; the handler mutates the child, leaving the parent's assigns unchanged →pre_assigns == post_assigns→skip_render = True→_send_noopfired BEFORE the embedded-child render branch (which builds the scopedembedded_updateframe) could run. Fix (Python-only): bindchange_target = target_viewand take the pre/post assigns + push-command identity snapshots — and read_skip_render/_force_full_html/_pending_push_events, write_changed_keys— againstchange_target. For a top-level eventtarget_view IS self.view_instance, so the common path is unchanged; for an embedded child the mutation is now detected and the existingembedded_updateframe (full child HTML, applied client-side via45-child-view.js'shandleEmbeddedUpdateagainst[data-djust-embedded]) is sent. The LiveComponent (component_id) path and existing sticky render/redirect/persistence behavior are unaffected. Regression coverage:test_embedded_sticky_child_event_produces_update_not_noop(realWebsocketCommunicator: mounts a parent embedding asticky=TrueNotificationsView, firesdismisswithview_idin params, asserts anembedded_updatereflecting the mutated state — notnoop), a standalone control, and achange_targetsource pin inpython/djust/tests/test_sticky_child_event_noop_1802.py. Gate-off (change_target = self.view_instance) makes the integration test fail with{'type': 'noop'};tests/integration/test_sticky_redirect_flow.pyandtest_sticky_http_get_1784.pystill pass.App-template dir collector uses
is_dir()to matchDjustTemplateBackend(#1805).utils._get_template_dirs_cached(the cached APP_DIRS collector used by the shell render) guarded each app'stemplatespath withexists(), while djust's ownDjustTemplateBackend._get_template_dirs(template/backend.py) usesis_dir(). The two parallel-path collectors disagreed: withexists(), a plain file literally namedtemplates(no extension) would be wrongly added to the template search dirs. Switched the cached helper tois_dir()so both reject non-directories identically (pre-existing tech-debt surfaced in the #1804/#1801 review; harmless in practice). New cases inTestCollectorIsDirGuardpin that a file namedtemplatesis excluded while a realtemplates/directory is still collected (gate-off verified against the pre-fixexists()guard). Also documents that thetest_resolution_failure_is_logged_not_silentmonkeypatch target depends on thefrom djust._rust import resolve_template_inheritanceimport staying insideget_template().{% extends %}pages now keep the base template's<head>on the initial HTTP GET (#1801). ALiveViewwhose template{% extends "base.html" %}served an initial GET containing only thedj-rootsubtree (no<!doctype>/<html>/<head>/<title>/<style>from the base template), so every template-inheritance page — including the untoucheddjust newscaffold — rendered unstyled on first paint. Root cause (traced symptom-up against the real scaffold):get_template()collected the Rust resolver's template search directories with a hardcoded backend-name check that recognized onlydjango.template.backends.django.DjangoTemplates. The scaffold (and any project) configuring djust's own backenddjust.template.backend.DjustTemplateBackendwithAPP_DIRS=Truehad its app-template directories silently dropped, soresolve_template_inheritanceraisedRuntimeError: Template error: Template not found— which was swallowed by a broadexcept Exceptionthat logged only at DEBUG and setself._full_template = None.render_full_templatethen fell through to itselse(return self.render(request)) → the baredj-rootfragment with no shell/head. Two-part fix: (1) the APP_DIRS dir-collection now recognizes the djust backend(s) via a sharedutils._APP_DIRS_TEMPLATE_BACKENDSset, andget_template()resolves dirs through the singleget_template_dirs()helper it already shared withrender_full_templatestep 2 — retiring the parallel-path-drift between the two (#1646):utils._get_template_dirs_cached()(used by the shell render) had the identical hardcoded check, so a point fix in only one place would have left the shell render broken for the same reason. (2) The broad swallow is narrowed to scope only theresolve_template_inheritancecall (the legitimate raw-template fallback for genuinely-unresolvable templates) and now logs at WARNING — post-resolution VDOM extraction/strip is moved outside thetryso an unexpected framework error surfaces instead of silently degrading to fragment-only. Verified against a freshdjust new demo --no-setupGET: the response now starts with<!DOCTYPE html>and includes the base<head>/<title>/<style>. Regression coverage:test_extends_get_includes_base_head,test_full_template_is_populated_for_extends,test_app_template_dirs_collected_for_djust_backend, andtest_resolution_failure_is_logged_not_silentintests/integration/test_extends_head_initial_get_1801.pydrive the realas_view()GET under djust's own backend (the exact config the bug reproduces under); gating the backend-set fix off makes three fail fragment-only and gating the WARNING off makes the logging test fail (silent-catch pin). Non-extends LiveViews and the existingtest_sticky_http_get_1784.py/ SSR-parity suites are unaffected.Serial-order test pollution that broke
test_checksS005 +auto_navigate_meta(#1794). Under the broad serial pytest ordering (pytest python//make test-python), three tests failed that pass both in isolation and under the parallelmake test -n autogate (which isolates per-worker and never collectspython/djust/tests/):test_checks.py::TestS005UnauthenticatedViews::test_s005_suppressed_with_login_required_false, andtest_client_config_tag.py::test_auto_navigate_meta_emitted_when_enabled/::..._engines_identical. Two independent polluters, neither asettings.DATABASES/LIVEVIEW_CONFIGleak (the reported hypothesis): (1)python/djust/tests/test_ws_auth_close_socket.pydefines a module-levelLiveViewsubclass_PublicViewwith nologin_requiredand exposed state (self.ok), so it permanently joinsLiveView.__subclasses__()and thedjust.S005check fired on it ("PublicView" in msg) for any later test asserting the S005 result set — fixed by marking itlogin_required = False("intentionally public"), which is also its actual contract; (2)tests/unit/test_ws_compression_config.py::_fresh_configcalledimportlib.reload(djust.config), rebindingdjust.config.configto a new singleton while everyfrom djust.config import configconsumer (notablydjust.templatetags.live_tags) kept the old reference — so theauto_navigatetests reset the new singleton whilelive_tagsread the stale one and never emitted the<meta>— fixed by re-reading settings viaconfig.reset()on the shared singleton (same effect, no orphaning) plus an autouse teardown fixture. Verified with three consecutive clean serialpytest tests/ python/tests/ python/djust/tests/runs (7692 passed each). Test-only changes; no framework behavior change.Pre-push hook (and
maketargets) now resolve the project venv from any git worktree (#1796). The native pre-push hook entries in.pre-commit-config.yaml— and ~31maketargets — hardcoded.venv/bin/pythonrelative to the current working directory. Agit worktree(e.g. the ones pipeline-drain subagents create under.claude/worktrees/) has no.venvof its own, so the hook failed withbash: .venv/bin/python: No such file or directory(exit 127), forcinggit push --no-verifyand skipping the real gates. Newscripts/run-with-venv-python.shresolves the interpreter relative to the MAIN working tree root (dirnameof the absolute--git-common-dir, which points at<main-root>/.gitfor both the main checkout and every linked worktree), falling back touv run pythonthenpython3on PATH when no.venvexists (CI, fresh clone). All 7 hook entries route through it, and the Makefile's hardcoded references collapse to a single$(PYTHON)variable computed once via the resolver — so the pre-push gates andmake testboth run from any worktree instead of erroring. 6 regression cases intests/test_run_with_venv_python.py(realgit worktreeresolution, main-checkout no-regression,python3fallback, no-interpreter error, plus source-pins on the config and Makefile); the worktree case fails against the pre-#1796 resolver via the gate-off self-test.djust newscaffold is now warning-clean and the deprecatedcli.py startprojecttwin no longer ships broken templates (#1791, follow-up to #1787/#1790). After #1790 fixed the boot blockers, a freshdjust newproject passedmanage.py check(exit 0) but still emitted five warnings; it now emits zero. Fixed in the canonical scaffolder (python/djust/scaffolding/templates.py+generator.py): C012 —base.htmlloads{% load live_tags %}and uses{% djust_client_config %}instead of a manual<script src=".../client.js">tag (the LiveView post-processing pipeline auto-injectsclient.js, so a manual tag double-loads); S005 — the in-memory demo view (and the--with-dbdemo view) declareslogin_required = Falseto acknowledge it is an intentionally-public to-do list with no per-user data; Y001/Y003 —index.html's icon-only toggle/delete buttons getaria-labels and the search/add-item inputs getaria-labels; A030 —django.contrib.adminis now opt-in (the default in-memory scaffold omits it, eliminating the brute-force-protection warning and the admin-only secondDjangoTemplatesbackend), while--with-db/--from-schemastill wire admin + its template backend + the/admin/URL, where A030 fires by design as correct security guidance. Separately, the deprecateddjust startprojectcommand carried its own divergent project templates that still shipped the brokenapplication = live_session()ASGI app and the droppeddaphnestack (the same #1787 bug); rather than maintain a second drift-prone template set (parallel-path-drift),cmd_startprojectnow prints a deprecation notice and delegates to the canonicalgenerate_project(), producing the same warning-clean, uvicorn-booting project. Regression coverage:TestScaffoldWarningClean1791(tests/integration/test_scaffold_boot_1787.py) asserts a fresh scaffold'smanage.py checkemits zeroWARNINGSand none ofdjust.C012/S005/Y001/Y003/A030; the rewrittenTestStartProjectDeprecated(python/tests/test_cli_scaffold.py) pins the deprecation+delegation contract. Gate-off self-test confirmed the warning-clean assertion is non-tautological.Request + context-processor outputs no longer leak into persisted LiveView state (#1786). A
LiveViewwhosemount()assigned only JSON-serializable scalars still emitted, on every render/event, a flood ofserializationwarnings naming the request and the standard context-processor outputs (ASGIRequest/WSGIRequest, authPermWrapper, messagesFallbackStorage,SimpleLazyObject/UserLazyObject) — values the view never assigns toself. It also bloated the_prev_context_refschange-detection fingerprint (thedict '_prev_context_refs' has N keys — fingerprint truncatedwarning) and inflated the state written to the Redis state backend. Root cause:_sync_state_to_rustfolds the request + context-processor outputs into the render context via_apply_context_processors; on the first render (and on every event, since those values get a freshid()each cycle) they flowed throughnormalize_django_value(one warning per value) and into the_prev_context_refsfingerprint. Fix:_apply_context_processorsnow records the keys it added onself._context_processor_keys;_sync_state_to_rustexcludes those keys (plusrequest) from the change-detection fingerprint and theset_changed_keysskip set, and skips the non-serializable ones from theupdate_state/normalize_django_valuewarning path. The non-serializable values still reach the Rust template via the existing raw-value sidecar (set_raw_py_values), so{{ user }}/{% csrf_token %}keep rendering (the #1779 contract is preserved); genuine user-assigned public state is untouched, so WS-reconnect restore and time-travel snapshots are unaffected. Regression coverage inTestContextProcessorStateLeak1786(python/tests/test_context_processor_state_leak_1786.py) — asserts zero non-serializable warnings on render, the request-scoped keys are absent from_prev_context_refsand the serialized Rust state, and{{ user }}/{% csrf_token %}still render; fails against the pre-#1786 code.Embedded
{% live_render "...View" sticky=True %}now server-renders on the initial HTTP GET (#1784). Any page whose template embedded{% live_render %}returned HTTP 500 on the first load — so sticky / app-shell pages (a headline feature) could not be server-rendered at all. The page shell (including thelive_rendertag) is rendered through the Rust engine with a JSON-serialized context that structurally cannot carry the live parentLiveViewobject; the tag looked the parent up viacontext.get("view")/context.get("self")(both absent) and raisedTemplateSyntaxError: {% live_render %} must be called inside a LiveView template; no parent view in the current render context. Fix (no Rust changes): an active-parent-view thread-local +active_parent_view()context manager (save/restore, nesting-safe, always cleared on error) indjust.templatetags.live_tags; bothrender_full_templateandrender_with_diffregisterselfas the active parent for the duration of their Rust render (both re-run the tag through the Rust engine on the GET path), andlive_renderfalls back to the thread-local when the render context has noview/selfand to the parent's liverequestwhen the JSON-serialized request was stringified. The WS / Django-engine paths carry a realviewin context, so the fallback is inert for them and existing sticky preservation acrosslive_redirectis unchanged.sticky_demo(the only embedded-{% live_render %}example app, and the only demo app not wired into the demo project) is now wired intodemo_projecturls +INSTALLED_APPSso the initial-GET server-render path is exercised end-to-end — the gap that let the bug ship unexercised. Regression coverage intests/integration/test_sticky_http_get_1784.py(test_sticky_live_render_http_get_returns_200,test_sticky_live_render_http_get_includes_child_html, andtest_sticky_demo_dashboard_http_get_200) drives the realas_view()GET path for both the inline-template and template-inheritance branches; all three fail against the unfixedrender_full_template.djust newnow scaffolds a project that actually boots (#1787). The generatedasgi.pydidapplication = live_session(), butlive_session(prefix, patterns, ...)is a URL-pattern helper (returnsList[URLPattern]), not an ASGI app — importing the scaffoldedasgi.pyraisedTypeError: live_session() missing 2 required positional arguments, somake devcrashed immediately. The asgi template now lifts thedemo_project/asgi.pypattern: aProtocolTypeRouterwhose"http"isASGIStaticFilesHandler(get_asgi_application())(servesclient.js/CSS under uvicorn with no WhiteNoise) and whose"websocket"isAllowedHostsOriginValidator(AuthMiddlewareStack(URLRouter([path("ws/live/", LiveViewConsumer.as_asgi())]))), withget_asgi_application()called before the channels/djust imports so the app registry is populated. The dev stack moves off daphne to uvicorn: the Makefiledevtarget now runsuvicorn <name>.asgi:application --host 127.0.0.1 --port 8000 --reload,requirements.txtdropsdaphne>=4.0foruvicorn[standard]>=0.30, andINSTALLED_APPSlists"channels"instead of"daphne". Separately, a freshly-scaffolded project failedmanage.py check(which blocksmigrate) on two ERRORS:djust.A014(the scaffold ran in production mode —settings.pyreados.environbut never loaded the generated.env, soDEBUGwas False and thedjango-insecure-key was flagged) andadmin.E403(noDjangoTemplatesbackend for the admin). settings.py now ships a dependency-free.envloader (os.environ.setdefaultperKEY=VALUEline, comments/blanks skipped) and the scaffolder writes a working.env(DEBUG=True + a realSECRET_KEY, gitignored) so dev mode is on out of the box; a secondDjangoTemplatesTEMPLATES backend satisfies the admin. Thedjango-insecure-prefix is retained as the production marker (A014 still fires in realDEBUG=Falsedeploys).manage.py checknow exits 0; the remaining warning-level items (C012 manual client.js, S005 unauth view, Y001/Y003 aria) are deferred to a follow-up. Regression coverage intest_scaffold_boot_1787.py(test_scaffold_asgi_imports_and_check_passesasserts the generatedasgi.pyimports +applicationis a callable ASGI app, andmanage.py checkexits 0; fails against the pre-#1787 templates).WebSocket recovery no longer forces a full page reload on the
html_updatefallback (#1785). When a LiveView event's VDOM diff returns no patches and the server sends a full-HTMLhtml_updateframe (the DJE-053 fallback), it now arms on-demand recovery — matching the patches path. Previously thehtml_updatebranch inhandle_eventskipped_arm_recovery, so a client that subsequently requested recovery (e.g. after a VDOM version mismatch on the full-HTML frame) receivedRecovery HTML unavailable — the server may have restartedand reloaded the whole page instead of morphing. Surfaced by a multi-replica djust.org/insights/page reloading on every time-range switch. Added aWebsocketCommunicatorregression test (TestWSRecoveryHtmlUpdate-style, intest_ws_recovery_html_update_1785.py) plus a source pin.