Skip to content

Commit 2e5def3

Browse files
committed
fix(hub): stop no-op state churn — docks republish, message re-adds, 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.
1 parent fb5789b commit 2e5def3

10 files changed

Lines changed: 226 additions & 19 deletions

File tree

examples/hub-vite/src/client/main.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,20 @@ function renderTransportToggle(current: TransportPref) {
7070
button.addEventListener('click', () => applyTransportPref(button.dataset.transport as TransportPref))
7171
}
7272

73+
const renderedMarkup = new WeakMap<HTMLElement, string>()
74+
7375
function renderList<T>(host: HTMLElement, items: readonly T[], render: (item: T) => string) {
74-
if (!items.length) {
75-
host.innerHTML = '<li class="rounded-lg border border-base bg-base border-dashed px2.5 py1.5 text-xs font-mono op-mute">empty</li>'
76+
const html = items.length
77+
? items.map(render).join('')
78+
: '<li class="rounded-lg border border-base bg-base border-dashed px2.5 py1.5 text-xs font-mono op-mute">empty</li>'
79+
// Skip identical rewrites: an innerHTML assignment always recreates the
80+
// nodes, which counts as a DOM mutation — and the a11y inspector's in-page
81+
// agent watches the body for mutations to schedule rescans. Repainting an
82+
// unchanged list every poll would keep it scanning forever.
83+
if (renderedMarkup.get(host) === html)
7684
return
77-
}
78-
host.innerHTML = items.map(render).join('')
85+
renderedMarkup.set(host, html)
86+
host.innerHTML = html
7987
}
8088

8189
// Session-lifetime cache of resolved dock-icon SVGs, keyed by the icon id

packages/devframe/src/adapters/__tests__/sse-e2e.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ describe('sse transport e2e — full client', () => {
223223
}
224224
})
225225

226-
it('shared state syncs over SSE', async () => {
226+
it('shared state syncs over SSE without echoing server updates back as POSTs', async () => {
227227
const { devframe, origin, base, close } = await bootServer('sse-client-state')
228228
try {
229229
const ctx = await devframe.context
@@ -232,19 +232,39 @@ describe('sse transport e2e — full client', () => {
232232
})
233233

234234
stubLocation(origin, base)
235+
let postCount = 0
235236
const client = await getDevframeRpcClient({
236237
baseURL: `${origin}${base}`,
237238
transport: 'sse',
238239
otpParam: false,
239240
simpleAuth: false,
241+
sseOptions: {
242+
fetch: (input, init) => {
243+
if (init?.method === 'POST')
244+
postCount++
245+
return fetch(input, init)
246+
},
247+
},
240248
})
241249
await client.ensureTrusted(5000)
242250

243251
const clientState = await client.sharedState.get<{ count: number }>('sse-test:counter')
244252
expect(clientState.value().count).toBe(1)
245253

254+
// Server-side ticks stream down; none of them may reflect back up as
255+
// a `server-state:set` POST — the echo the server would just discard.
256+
const postsBeforeTicks = postCount
246257
serverState.mutate(() => ({ count: 2 }))
247258
await vi.waitFor(() => expect(clientState.value().count).toBe(2))
259+
serverState.mutate(() => ({ count: 3 }))
260+
serverState.mutate(() => ({ count: 4 }))
261+
await vi.waitFor(() => expect(clientState.value().count).toBe(4))
262+
expect(postCount).toBe(postsBeforeTicks)
263+
264+
// A local mutation still forwards to the server (exactly once).
265+
clientState.mutate(() => ({ count: 5 }))
266+
await vi.waitFor(() => expect(serverState.value().count).toBe(5))
267+
expect(postCount).toBe(postsBeforeTicks + 1)
248268
client.close?.()
249269
}
250270
finally {

packages/devframe/src/client/rpc-shared-state.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,36 @@ import type { SharedState, SharedStatePatch } from 'devframe/utils/shared-state'
33
import type { DevframeRpcClient } from './rpc'
44
import { createSharedState } from 'devframe/utils/shared-state'
55

6+
/**
7+
* Upper bound on remembered server-originated syncIds. An update's own
8+
* `updated` event fires synchronously after it is applied, so the set only
9+
* needs to outlive the brief window between applying a server update and
10+
* observing its emission — 100 comfortably covers a burst.
11+
*/
12+
const MAX_REMOTE_SYNC_IDS = 100
13+
614
export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcSharedStateHost {
715
const sharedState = new Map<string, SharedState<any>>()
816
const stateDisposers = new Map<string, () => void>()
917
const initialValues = new Map<string, any>()
1018
const keyAddedListeners = new Set<(key: string) => void>()
1119
const isStaticBackend = rpc.connectionMeta.backend === 'static'
1220

21+
// Server-originated syncIds, so the forwarding listener below can tell a
22+
// local mutation (forward it to the server) from an applied server update
23+
// (already the server's own — forwarding it back would be a pure echo the
24+
// server discards, at the cost of one wire message per update; over the
25+
// SSE transport that's a whole HTTP POST per server-side state tick).
26+
const remoteSyncIds = new Set<string>()
27+
function rememberRemoteSyncId(syncId: string): void {
28+
remoteSyncIds.add(syncId)
29+
if (remoteSyncIds.size > MAX_REMOTE_SYNC_IDS) {
30+
const oldest = remoteSyncIds.values().next().value
31+
if (oldest !== undefined)
32+
remoteSyncIds.delete(oldest)
33+
}
34+
}
35+
1336
function mergeWithInitialValue(key: string, serverState: any): any {
1437
const initial = initialValues.get(key)
1538
if (initial && typeof initial === 'object' && !Array.isArray(initial)
@@ -26,6 +49,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
2649
const state = sharedState.get(key)
2750
if (!state || state.syncIds.has(syncId))
2851
return
52+
rememberRemoteSyncId(syncId)
2953
state.mutate(() => mergeWithInitialValue(key, fullState), syncId)
3054
},
3155
})
@@ -37,6 +61,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
3761
const state = sharedState.get(key)
3862
if (!state || state.syncIds.has(syncId))
3963
return
64+
rememberRemoteSyncId(syncId)
4065
state.patch(patches, syncId)
4166
},
4267
})
@@ -46,6 +71,9 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
4671
offs.push(state.on('updated', (fullState, patches, syncId) => {
4772
if (isStaticBackend)
4873
return
74+
// An update the server just sent needs no reflection back to it.
75+
if (remoteSyncIds.has(syncId))
76+
return
4977
if (patches) {
5078
rpc.callEvent('devframe:rpc:server-state:patch', key, patches, syncId)
5179
}

packages/hub/src/node/__tests__/context.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,49 @@ describe('createHubContext shared state', () => {
2929
})
3030
})
3131

32+
describe('createHubContext docks state churn', () => {
33+
it('message events republish the docks state only when the dock list changed', async () => {
34+
const context = await createHubContext({
35+
cwd: process.cwd(),
36+
mode: 'build', // debounceMs = 0 — republishes settle synchronously-ish
37+
host: createHost(),
38+
})
39+
context.docks.register({
40+
type: 'iframe',
41+
id: 'devframes_plugin_terminals',
42+
title: 'Terminals',
43+
icon: 'ph:terminal-window-duotone',
44+
url: '/__devframes_plugin_terminals/',
45+
})
46+
const docks = await context.rpc.sharedState.get<DevframeDockEntry[]>('devframe:docks')
47+
// Let the register's debounced publish settle first.
48+
await vi.waitFor(() => expect(docks.value()).toHaveLength(1))
49+
50+
let publishes = 0
51+
const off = docks.on('updated', () => void publishes++)
52+
53+
// The periodic-producer pattern: message adds/updates that leave the
54+
// dock list untouched must not rebroadcast `devframe:docks`.
55+
await context.messages.add({ id: 'scan', level: 'info', message: 'No issues found' })
56+
await context.messages.add({ id: 'scan', level: 'info', message: '2 issues found' })
57+
await context.messages.add({ id: 'scan', level: 'info', message: '3 issues found' })
58+
await new Promise(resolve => setTimeout(resolve, 30))
59+
expect(publishes).toBe(0)
60+
61+
// A real dock change still publishes.
62+
context.docks.register({
63+
type: 'iframe',
64+
id: 'second',
65+
title: 'Second',
66+
icon: 'ph:cube-duotone',
67+
url: '/__second/',
68+
})
69+
await vi.waitFor(() => expect(publishes).toBeGreaterThan(0))
70+
expect(docks.value()).toHaveLength(2)
71+
off()
72+
})
73+
})
74+
3275
describe('createHubContext dock activation', () => {
3376
it('mirrors an activation into shared state and broadcasts it live', async () => {
3477
const context = await createHubContext({

packages/hub/src/node/__tests__/host-messages.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,47 @@ describe('devframeMessagesHost', () => {
2121
expect(host.removals.at(-1)?.id).toBe('message:1004')
2222
})
2323

24+
it('dedupes identical re-adds: no update event, no clock tick', async () => {
25+
const host = new DevframeMessagesHost({} as DevframeHubContext)
26+
const updates: string[] = []
27+
host.events.on('message:updated', entry => void updates.push(entry.id))
28+
29+
const input = { id: 'scan', level: 'info' as const, message: 'No issues found', labels: ['a11y'] }
30+
await host.add(input)
31+
const tickAfterAdd = host.lastModified.get('scan')
32+
33+
// The periodic-producer pattern: the same entry mirrored again and again.
34+
await host.add({ ...input })
35+
await host.add({ ...input, labels: ['a11y'] })
36+
expect(updates).toEqual([])
37+
expect(host.lastModified.get('scan')).toBe(tickAfterAdd)
38+
39+
// A real change still updates and emits.
40+
await host.add({ ...input, message: '2 issues found' })
41+
expect(updates).toEqual(['scan'])
42+
expect(host.entries.get('scan')?.message).toBe('2 issues found')
43+
expect(host.lastModified.get('scan')).not.toBe(tickAfterAdd)
44+
})
45+
46+
it('an identical re-add carrying autoDelete still resets the keep-alive timer', async () => {
47+
const host = new DevframeMessagesHost({} as DevframeHubContext)
48+
const updates: string[] = []
49+
host.events.on('message:updated', entry => void updates.push(entry.id))
50+
51+
await host.add({ id: 'alive', level: 'info', message: 'still here', autoDelete: 50 })
52+
// Keep-alive re-adds: content identical, timer restarted each time.
53+
for (let i = 0; i < 3; i++) {
54+
await new Promise(resolve => setTimeout(resolve, 30))
55+
await host.add({ id: 'alive', level: 'info', message: 'still here', autoDelete: 50 })
56+
}
57+
// 90ms elapsed — well past the 50ms window — yet the entry survives.
58+
expect(host.entries.has('alive')).toBe(true)
59+
expect(updates).toEqual([])
60+
// Once the re-adds stop, the timer finally fires.
61+
await new Promise(resolve => setTimeout(resolve, 80))
62+
expect(host.entries.has('alive')).toBe(false)
63+
})
64+
2465
it('provides per-level shortcuts that delegate to add()', async () => {
2566
const host = new DevframeMessagesHost({} as DevframeHubContext)
2667

packages/hub/src/node/context.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { DevframeMessageEntry, DevframeMessageEntryInput, DevframeMessagesH
66
import type { DevframeTerminalsHost } from '../types/terminals'
77
import type { InstallDevframeOptions } from './install-devframe'
88
import { createHostContext } from 'devframe/node'
9+
import { hash } from 'devframe/utils/hash'
910
import { debounce } from 'perfect-debounce'
1011
import { DevframeCommandsHost as CommandsHostImpl } from './host-commands'
1112
import { DevframeDocksHost as DocksHostImpl } from './host-docks'
@@ -151,11 +152,25 @@ export async function createHubContext(options: CreateHubContextOptions): Promis
151152
const debounceMs = options.mode === 'build' ? 0 : 10
152153

153154
const docksSharedState = await context.rpc.sharedState.get('devframe:docks', { initialValue: [] })
155+
// The docks state republishes on dock, terminal, *and* message events —
156+
// most of which don't actually change the dock list. Publish only when
157+
// the content really changed, so a chatty subsystem (e.g. a message feed
158+
// being mirrored every scan) can't turn into a stream of identical
159+
// `devframe:docks` broadcasts.
160+
let publishedDocksHash: string | undefined
161+
function publishDocks(): void {
162+
const values = docks.values()
163+
const digest = hash(values)
164+
if (digest === publishedDocksHash)
165+
return
166+
publishedDocksHash = digest
167+
docksSharedState.mutate(() => values)
168+
}
154169
const refreshDocks = debounce(() => {
155-
docksSharedState.mutate(() => docks.values())
170+
publishDocks()
156171
}, debounceMs)
157172
docks.events.on('dock:entry:updated', refreshDocks)
158-
docksSharedState.mutate(() => docks.values())
173+
publishDocks()
159174

160175
// Cross-iframe dock activation. A dock activation is a discrete user intent
161176
// ("go to Terminals now"), so it fires immediately (no debounce, which could
@@ -181,7 +196,7 @@ export async function createHubContext(options: CreateHubContextOptions): Promis
181196
method: 'devframe:terminals:updated',
182197
args: [],
183198
})
184-
docksSharedState.mutate(() => docks.values())
199+
publishDocks()
185200
}, debounceMs)
186201
terminals.events.on('terminal:session:updated', broadcastTerminals)
187202

@@ -190,7 +205,7 @@ export async function createHubContext(options: CreateHubContextOptions): Promis
190205
method: 'devframe:messages:updated',
191206
args: [],
192207
})
193-
docksSharedState.mutate(() => docks.values())
208+
publishDocks()
194209
}, debounceMs)
195210
messages.events.on('message:added', broadcastMessages)
196211
messages.events.on('message:updated', broadcastMessages)

packages/hub/src/node/host-messages.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
} from '../types/messages'
99
import type { DevframeHubContext } from './context'
1010
import { createEventEmitter } from 'devframe/utils/events'
11+
import { hash } from 'devframe/utils/hash'
1112
import { nanoid } from 'devframe/utils/nanoid'
1213

1314
const MAX_ENTRIES = 1000
@@ -93,9 +94,17 @@ export class DevframeMessagesHost implements DevframeMessagesHostType {
9394
timestamp: existing.timestamp,
9495
}
9596

96-
this.entries.set(id, updated)
97-
this.lastModified.set(id, this._tick())
98-
this.events.emit('message:updated', updated)
97+
// Content dedupe: a re-add/update that changes nothing (a periodic
98+
// producer mirroring the same entries — e.g. a scanner re-reporting an
99+
// unchanged result) emits no event, so it can't fan out into broadcast
100+
// and shared-state churn. An identical patch that carries `autoDelete`
101+
// still falls through below to reset the keep-alive timer.
102+
const unchanged = hash(updated) === hash(existing)
103+
if (!unchanged) {
104+
this.entries.set(id, updated)
105+
this.lastModified.set(id, this._tick())
106+
this.events.emit('message:updated', updated)
107+
}
99108

100109
// Reset autoDelete timer if changed
101110
if (patch.autoDelete !== undefined) {

plugins/a11y/src/inject/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,12 @@ function start(context?: A11yAgentContext) {
126126

127127
function scheduleScan() {
128128
clearTimeout(debounceTimer)
129-
debounceTimer = window.setTimeout(runScan, 600)
129+
// Observer/interaction-driven rescans are background refreshes: they
130+
// update the report silently instead of flipping the messages-feed
131+
// summary through loading → idle — that status churn is itself a page
132+
// mutation (the host re-renders its feed), which would re-trigger the
133+
// observer and turn the scan into a self-sustaining loop.
134+
debounceTimer = window.setTimeout(() => void runScan({ background: true }), 600)
130135
}
131136

132137
// Interaction-driven rescans, layered on top of the DOM observer. Bound only
@@ -176,15 +181,16 @@ function start(context?: A11yAgentContext) {
176181
console.groupEnd()
177182
}
178183

179-
async function runScan() {
184+
async function runScan(options: { background?: boolean } = {}) {
180185
if (scanning) {
181186
rescanQueued = true
182187
return
183188
}
184189
scanning = true
185190
activeRoute = location.pathname
186191
post({ type: 'a11y:scanning', route: activeRoute })
187-
reporter?.scanning()
192+
if (!options.background)
193+
reporter?.scanning()
188194
// Suspend observation so attribute-stamping during the scan doesn't
189195
// retrigger us.
190196
observer.disconnect()

plugins/a11y/src/inject/messages.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,23 @@ export function createMessagesReporter(
113113
let reportedRules = new Set<string>()
114114
// Fire-and-forget: the feed is a mirror, never a gate for the scan loop.
115115
// Every entry is grouped under the short `a11y` category.
116-
const send = (input: HubMessageInput) =>
116+
//
117+
// Re-scans routinely produce the identical report; sending it again would
118+
// be one wire message per entry for zero feed change (and the feed
119+
// re-render it causes on the host page can re-trigger the DOM observer —
120+
// a scan loop). Remember what each entry last carried and send only diffs.
121+
const lastSent = new Map<string, string>()
122+
const send = (input: HubMessageInput & { id: string }) => {
123+
const digest = JSON.stringify(input)
124+
if (lastSent.get(input.id) === digest)
125+
return
126+
lastSent.set(input.id, digest)
117127
void messages.add({ category: MESSAGE_CATEGORY, ...input }).catch(() => {})
118-
const drop = (id: string) => void messages.remove(id).catch(() => {})
128+
}
129+
const drop = (id: string) => {
130+
lastSent.delete(id)
131+
void messages.remove(id).catch(() => {})
132+
}
119133
const dockId = () => options.dockId?.() ?? A11Y_DEFAULT_DOCK_ID
120134

121135
return {

0 commit comments

Comments
 (0)