Skip to content

fix(hub): stop no-op state churn — docks republish, message re-adds, a11y scan loop - #196

Merged
antfu merged 1 commit into
feat/sse-transportfrom
fix/hub-state-churn
Aug 13, 2026
Merged

fix(hub): stop no-op state churn — docks republish, message re-adds, a11y scan loop#196
antfu merged 1 commit into
feat/sse-transportfrom
fix/hub-state-churn

Conversation

@antfubot

Copy link
Copy Markdown
Collaborator

Intent

In examples/hub-vite, the devframe:docks shared state rebroadcast every ~1s with identical content, and hub:messages:add fired continuously — on any transport (the SSE work in #195 merely made it visible as an HTTP request stream). Stacked on #195 because its tests build on the SSE e2e harness; the bugs themselves predate it.

Root cause — a self-sustaining feedback loop

  1. The a11y in-page agent's MutationObserver schedules a rescan on any body mutation (600ms debounce, no content gating).
  2. Every scan mirrored itself into the messages feed unconditionally: the summary entry flipped loading → idle each pass, plus one identical add per violated rule.
  3. The messages host had no content dedupe — an identical re-add still emitted message:updated.
  4. Every message event republished the whole devframe:docks shared state with a fresh array and no change detection (and SharedState.mutate broadcasts unconditionally).
  5. The hub-vite client re-rendered the dock rail with an unconditional innerHTML rewrite — which is a DOM mutation → back to (1). Period ≈ debounce + scan time ≈ 1s.

hub-next was immune only by accident: React reconciliation no-ops identical lists, so the observer stayed quiet — which confirmed the diagnosis.

Fixes (independent; any one breaks the cycle, together they also kill the wire waste)

  • Hub context — hash-guard (devframe/utils/hash) the devframe:docks republish: dock/terminal/message events publish only when the dock list content actually changed.
  • Messages host — content dedupe in update(): identical re-adds emit nothing and don't bump the delta clock; an identical re-add carrying autoDelete still resets the keep-alive timer.
  • A11y agent — observer/interaction-driven rescans run in the background (no loading → idle summary churn); the messages reporter digest-gates each entry and re-sends only real changes (a removed rule that reappears is re-sent).
  • hub-vite examplerenderList skips identical innerHTML rewrites, so repainting an unchanged list is no longer a DOM mutation. (hub-next needs no change — React already reconciles.)
  • Shared-state client host — applied server updates are no longer reflected back as server-state:set/patch events the server discards by syncId: one wasted wire message (a whole HTTP POST over SSE) per server-side state tick, on any transport.

Verification

  • New tests: messages-host dedupe + autoDelete keep-alive; context-level "message events don't republish an unchanged dock list"; a11y reporter digest-gating (including rule-reappears re-send); SSE e2e asserts zero echo POSTs during server state ticks and exactly one POST per local mutation.
  • Full suite 1103/1103, typecheck, lint, knip clean.
  • Live check: an idle hub-vite over SSE now settles to exactly its intentional 2s drawer poll (20 parked-200 POSTs in a 20s window, zero 202s) — previously a continuous sub-second stream of identical ~2.7KB message-add frames.

Created with the help of an agent.

…a11y scan loop

In the hub-vite example the `devframe:docks` shared state rebroadcast
every ~1s with identical content and `hub:messages:add` fired
continuously. Root cause: a self-sustaining feedback loop —

  a11y agent scan → messages.add (summary flips loading→idle + one
  entry per rule, identical content) → message:updated (no dedupe) →
  docks shared-state republish (no change detection, fresh array every
  time) → client re-render → unconditional innerHTML rewrite → DOM
  mutation → a11y MutationObserver → scan (600ms debounce ≈ 1s period)

hub-next was immune only because React reconciliation no-ops identical
lists, keeping the MutationObserver quiet. Independent fixes, each of
which breaks the cycle on its own:

- hub context: hash-guard the `devframe:docks` republish — dock,
  terminal, and message events publish only when the dock list content
  actually changed.
- messages host: content dedupe in `update()` — an identical re-add
  emits no `message:updated` and bumps no clock; an identical re-add
  carrying `autoDelete` still resets the keep-alive timer.
- a11y agent: observer/interaction-driven rescans run in the background
  (no loading→idle summary churn), and the messages reporter skips
  re-sending entries whose content is unchanged since the last scan.
- hub-vite example: `renderList` skips identical innerHTML rewrites so
  repainting an unchanged list is no longer a DOM mutation.
- shared-state client host: applied server updates are no longer
  reflected back to the server as `server-state:set`/`patch` events
  the server would just discard (one wasted wire message — a whole
  HTTP POST over SSE — per server-side state tick, on any transport).

Verified live: with the fixes, an idle hub-vite over SSE settles to
exactly its intentional 2s drawer poll (all parked 200s) — the 202
message-add storm and docks broadcasts are gone.
@antfubot
antfubot force-pushed the fix/hub-state-churn branch from 5740c1f to 2e5def3 Compare August 13, 2026 04:38
@antfu
antfu merged commit d8ca9f7 into feat/sse-transport Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants