perf: elide unchanged connection-scoped router fields from deltas
What
Every client-side navigation fires on_load_internal, which reassigns state.router and marks the whole var dirty — so every navigation delta re-ships the complete RouterData, including the session block and every request header (twice, via raw_headers). Measured on a minimal app with no cookies:
- navigation delta: 1,870 bytes, of which 1,323 bytes (71%) are session + headers that cannot change for the life of the websocket
- apps with real auth cookies re-ship those on every page change too
This lands on the client on every nav (and every event that carries a changed router_data), gets JSON-parsed, and replaces the router value in the root state context.
How
Ship session/headers only when they actually changed:
BaseStateEventProcessorcompares the previous router when reassigning; if session and headers are unchanged it arms a transient_router_static_unchangedflag on the root state (never pickled meaningfully — recomputed on every reassignment).get_deltathen serializes only the per-navigation fields (page,url,route_id) for the router var viaserialize_partial_router_data(extracted from the existing serializer, so the two can't drift).applyDeltaon the frontend merges partial router payloads over the previously received value, so session/headers carry forward on the client.- Fail-safe: any direct
routerwrite clears the flag in__setattr__— covering the connect handler's sid update and the linked-tokenclient_tokenrewrite — falling back to the full payload.
Reconnects and cross-worker moves are safe by construction: a new socket has a new session_id, so the comparison fails and the full router is sent; a state restored on another worker has empty router_data (excluded from pickle), which also fails the comparison.
Compatibility
Rolling deployments / cached frontends. A pre-upgrade frontend replaces the router value instead of merging, so it must never receive a partial payload. The frontend already advertises the exact version it was compiled by as the websocket subprotocol, and the backend already compares it; this PR turns that existing handshake into the capability gate. on_connect records subprotocol == backend version on the root state (_partial_router_capable), and get_delta sends the partial payload only when it is set. Older bundles, proxies that strip the subprotocol, and polling transports all fall back to the full router in every delta. The capability survives pickling and worker moves, and reconnects re-evaluate it — a tab that reconnects with a stale cached bundle after a redeploy is downgraded to full payloads. Verified live by forcing a mismatched subprotocol from a real browser client: every navigation delta carried the full session/headers.
Internal fields are reserved. _router_static_unchanged (transient, never pickled) and _partial_router_capable are declared is_var=False fields on BaseState, added to RESERVED_BACKEND_VAR_NAMES, and __init_subclass__ raises ReservedStateFieldError if a user state declares either name — a collision surfaces as an error instead of silently steering delta serialization.
Measured result
- Navigation delta drops 1,870 → 516 bytes (−72%) on the minimal app.
- Rendered router vars (
State.router.session.client_token,State.router.headers.user_agent) verified intact on the client after slim deltas, across navigations and events, on a fresh boot. - New unit test
test_partial_router_deltacovers full-on-first-send, partial-when-unchanged, and full-again-after-direct-write.test_dynamic_route_var_route_change_completed_on_loadupdated: itsrouter_datacarries no sid/headers, so the partial payload correctly applies from the first on_load. Full unit suite: 7,460 passed.