Skip to content

Commit 7b1b309

Browse files
committed
perf: more efficient logs listing
1 parent ddd655d commit 7b1b309

File tree

6 files changed

+380
-16
lines changed

6 files changed

+380
-16
lines changed

docs/.vitepress/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const DevToolsKitNav = [
1414
{ text: 'Dock System', link: '/kit/dock-system' },
1515
{ text: 'RPC', link: '/kit/rpc' },
1616
{ text: 'Shared State', link: '/kit/shared-state' },
17-
{ text: 'Logs', link: '/kit/logs' },
17+
{ text: 'Logs & Notifications', link: '/kit/logs' },
1818
]
1919

2020
const SocialLinks = [

docs/kit/logs.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
# Logs
1+
# Logs & Notifications
22

33
The Logs system allows plugins to emit structured log entries from both the server (Node.js) and client (browser) contexts. Logs are displayed in the built-in **Logs** panel in the DevTools dock, and can optionally appear as toast notifications.
44

55
## Use Cases
66

7-
- **Accessibility audits** — Run axe or similar tools on the client side, report warnings with element positions
7+
- **Accessibility audits** — Run a11y checks or similar tools on the client side, report warnings with element positions
88
- **Runtime errors** — Capture and display errors with stack traces
99
- **Linting & testing** — Run ESLint or test runners alongside the dev server and surface results with file positions
1010
- **Notifications** — Short-lived messages like "URL copied" that auto-dismiss

packages/core/src/client/webcomponents/state/logs.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,22 @@ export function useLogs(context: DocksContext): Reactive<LogsState> {
2020
unreadCount: 0,
2121
})
2222

23-
const prevEntryMap = new Map<string, DevToolsLogEntry>()
23+
const entryMap = new Map<string, DevToolsLogEntry>()
2424
let isInitialFetch = true
25+
let lastVersion: number | undefined
2526

2627
async function updateLogs() {
27-
const logs = await context.rpc.call('devtoolskit:internal:logs:list')
28+
const result = await context.rpc.call('devtoolskit:internal:logs:list', lastVersion)
2829
let newCount = 0
2930

30-
for (const entry of logs) {
31-
const prev = prevEntryMap.get(entry.id)
31+
// Apply removals
32+
for (const id of result.removedIds)
33+
entryMap.delete(id)
34+
35+
// Apply new/updated entries
36+
for (const entry of result.entries) {
37+
const prev = entryMap.get(entry.id)
3238
if (!prev) {
33-
// New entry
3439
newCount++
3540
if (isInitialFetch) {
3641
// On initial fetch (page refresh), only toast entries still loading
@@ -43,18 +48,15 @@ export function useLogs(context: DocksContext): Reactive<LogsState> {
4348
}
4449
}
4550
else if (entry.notify && JSON.stringify(entry) !== JSON.stringify(prev)) {
46-
// Updated entry with notify flag — update the toast
4751
addToast(entry)
4852
}
53+
entryMap.set(entry.id, entry)
4954
}
5055

51-
state.entries = logs
56+
state.entries = Array.from(entryMap.values())
5257
state.unreadCount += newCount
58+
lastVersion = result.version
5359
isInitialFetch = false
54-
55-
prevEntryMap.clear()
56-
for (const entry of logs)
57-
prevEntryMap.set(entry.id, entry)
5860
}
5961

6062
context.rpc.client.register({
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
import type { DevToolsNodeContext } from '@vitejs/devtools-kit'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { DevToolsLogsHost } from '../host-logs'
4+
5+
describe('devToolsLogsHost', () => {
6+
const mockContext = {} as DevToolsNodeContext
7+
8+
function createHost() {
9+
return new DevToolsLogsHost(mockContext)
10+
}
11+
12+
describe('add()', () => {
13+
it('should add a log entry with auto-generated id and timestamp', () => {
14+
const host = createHost()
15+
const entry = host.add({ message: 'test', level: 'info' })
16+
17+
expect(entry.id).toBeDefined()
18+
expect(entry.message).toBe('test')
19+
expect(entry.level).toBe('info')
20+
expect(entry.timestamp).toBeTypeOf('number')
21+
expect(entry.source).toBe('server')
22+
expect(host.entries.size).toBe(1)
23+
})
24+
25+
it('should use provided id and timestamp', () => {
26+
const host = createHost()
27+
const entry = host.add({ id: 'my-id', message: 'test', level: 'warn', timestamp: 12345 })
28+
29+
expect(entry.id).toBe('my-id')
30+
expect(entry.timestamp).toBe(12345)
31+
})
32+
33+
it('should emit log:added event', () => {
34+
const host = createHost()
35+
const handler = vi.fn()
36+
host.events.on('log:added', handler)
37+
38+
const entry = host.add({ message: 'test', level: 'info' })
39+
40+
expect(handler).toHaveBeenCalledWith(entry)
41+
})
42+
43+
it('should dedup by id — delegates to update() if id exists', () => {
44+
const host = createHost()
45+
host.add({ id: 'dup', message: 'first', level: 'info' })
46+
const updated = host.add({ id: 'dup', message: 'second', level: 'warn' })
47+
48+
expect(host.entries.size).toBe(1)
49+
expect(updated.message).toBe('second')
50+
expect(updated.level).toBe('warn')
51+
// Preserves original id, source, timestamp
52+
expect(updated.id).toBe('dup')
53+
expect(updated.source).toBe('server')
54+
})
55+
56+
it('should evict oldest entry when at capacity', () => {
57+
const host = createHost()
58+
// Add 1000 entries (MAX_ENTRIES)
59+
for (let i = 0; i < 1000; i++)
60+
host.add({ id: `entry-${i}`, message: `msg ${i}`, level: 'info' })
61+
62+
expect(host.entries.size).toBe(1000)
63+
expect(host.entries.has('entry-0')).toBe(true)
64+
65+
// Adding one more should evict the first
66+
host.add({ id: 'overflow', message: 'overflow', level: 'info' })
67+
expect(host.entries.size).toBe(1000)
68+
expect(host.entries.has('entry-0')).toBe(false)
69+
expect(host.entries.has('overflow')).toBe(true)
70+
})
71+
})
72+
73+
describe('update()', () => {
74+
it('should update an existing entry', () => {
75+
const host = createHost()
76+
host.add({ id: 'u1', message: 'original', level: 'info' })
77+
const updated = host.update('u1', { message: 'changed', level: 'error' })
78+
79+
expect(updated).toBeDefined()
80+
expect(updated!.message).toBe('changed')
81+
expect(updated!.level).toBe('error')
82+
// Preserved fields
83+
expect(updated!.id).toBe('u1')
84+
expect(updated!.source).toBe('server')
85+
})
86+
87+
it('should return undefined for non-existent id', () => {
88+
const host = createHost()
89+
expect(host.update('nope', { message: 'x' })).toBeUndefined()
90+
})
91+
92+
it('should emit log:updated event', () => {
93+
const host = createHost()
94+
host.add({ id: 'u2', message: 'a', level: 'info' })
95+
const handler = vi.fn()
96+
host.events.on('log:updated', handler)
97+
98+
host.update('u2', { message: 'b' })
99+
100+
expect(handler).toHaveBeenCalledOnce()
101+
expect(handler.mock.calls[0][0].message).toBe('b')
102+
})
103+
104+
it('should preserve id, source, and timestamp on update', () => {
105+
const host = createHost()
106+
const original = host.add({ id: 'u3', message: 'orig', level: 'info' })
107+
const updated = host.update('u3', { message: 'new' })
108+
109+
expect(updated!.id).toBe(original.id)
110+
expect(updated!.source).toBe(original.source)
111+
expect(updated!.timestamp).toBe(original.timestamp)
112+
})
113+
})
114+
115+
describe('remove()', () => {
116+
it('should remove an entry', () => {
117+
const host = createHost()
118+
host.add({ id: 'r1', message: 'test', level: 'info' })
119+
host.remove('r1')
120+
121+
expect(host.entries.size).toBe(0)
122+
})
123+
124+
it('should emit log:removed event', () => {
125+
const host = createHost()
126+
host.add({ id: 'r2', message: 'test', level: 'info' })
127+
const handler = vi.fn()
128+
host.events.on('log:removed', handler)
129+
130+
host.remove('r2')
131+
132+
expect(handler).toHaveBeenCalledWith('r2')
133+
})
134+
})
135+
136+
describe('clear()', () => {
137+
it('should remove all entries', () => {
138+
const host = createHost()
139+
host.add({ message: 'a', level: 'info' })
140+
host.add({ message: 'b', level: 'warn' })
141+
host.clear()
142+
143+
expect(host.entries.size).toBe(0)
144+
})
145+
146+
it('should emit log:cleared event', () => {
147+
const host = createHost()
148+
host.add({ message: 'a', level: 'info' })
149+
const handler = vi.fn()
150+
host.events.on('log:cleared', handler)
151+
152+
host.clear()
153+
154+
expect(handler).toHaveBeenCalledOnce()
155+
})
156+
})
157+
158+
describe('autoDelete', () => {
159+
it('should auto-delete entry after timeout', () => {
160+
vi.useFakeTimers()
161+
const host = createHost()
162+
host.add({ id: 'ad1', message: 'temp', level: 'info', autoDelete: 1000 })
163+
164+
expect(host.entries.has('ad1')).toBe(true)
165+
vi.advanceTimersByTime(1000)
166+
expect(host.entries.has('ad1')).toBe(false)
167+
168+
vi.useRealTimers()
169+
})
170+
171+
it('should reset autoDelete timer on update', () => {
172+
vi.useFakeTimers()
173+
const host = createHost()
174+
host.add({ id: 'ad2', message: 'temp', level: 'info', autoDelete: 1000 })
175+
176+
vi.advanceTimersByTime(500)
177+
host.update('ad2', { autoDelete: 2000 })
178+
179+
vi.advanceTimersByTime(500)
180+
expect(host.entries.has('ad2')).toBe(true)
181+
182+
vi.advanceTimersByTime(1500)
183+
expect(host.entries.has('ad2')).toBe(false)
184+
185+
vi.useRealTimers()
186+
})
187+
188+
it('should clear autoDelete timer on remove', () => {
189+
vi.useFakeTimers()
190+
const host = createHost()
191+
host.add({ id: 'ad3', message: 'temp', level: 'info', autoDelete: 1000 })
192+
host.remove('ad3')
193+
194+
// Should not throw or re-remove after timer fires
195+
vi.advanceTimersByTime(1000)
196+
expect(host.entries.has('ad3')).toBe(false)
197+
198+
vi.useRealTimers()
199+
})
200+
})
201+
202+
describe('incremental versioning', () => {
203+
it('should track lastModified on add', () => {
204+
const host = createHost()
205+
host.add({ id: 'v1', message: 'a', level: 'info' })
206+
host.add({ id: 'v2', message: 'b', level: 'info' })
207+
208+
const mod1 = host.lastModified.get('v1')!
209+
const mod2 = host.lastModified.get('v2')!
210+
expect(mod1).toBeLessThan(mod2)
211+
})
212+
213+
it('should update lastModified on update', () => {
214+
const host = createHost()
215+
host.add({ id: 'v3', message: 'a', level: 'info' })
216+
const modBefore = host.lastModified.get('v3')!
217+
218+
host.update('v3', { message: 'b' })
219+
const modAfter = host.lastModified.get('v3')!
220+
221+
expect(modAfter).toBeGreaterThan(modBefore)
222+
})
223+
224+
it('should remove lastModified on remove', () => {
225+
const host = createHost()
226+
host.add({ id: 'v4', message: 'a', level: 'info' })
227+
host.remove('v4')
228+
229+
expect(host.lastModified.has('v4')).toBe(false)
230+
})
231+
232+
it('should track removals', () => {
233+
const host = createHost()
234+
host.add({ id: 'v5', message: 'a', level: 'info' })
235+
host.add({ id: 'v6', message: 'b', level: 'info' })
236+
host.remove('v5')
237+
238+
expect(host.removals).toHaveLength(1)
239+
expect(host.removals[0].id).toBe('v5')
240+
expect(host.removals[0].time).toBeGreaterThan(0)
241+
})
242+
243+
it('should track all removals on clear', () => {
244+
const host = createHost()
245+
host.add({ id: 'c1', message: 'a', level: 'info' })
246+
host.add({ id: 'c2', message: 'b', level: 'info' })
247+
host.clear()
248+
249+
expect(host.removals).toHaveLength(2)
250+
const ids = host.removals.map(r => r.id)
251+
expect(ids).toContain('c1')
252+
expect(ids).toContain('c2')
253+
})
254+
255+
it('should clear lastModified on clear', () => {
256+
const host = createHost()
257+
host.add({ id: 'c3', message: 'a', level: 'info' })
258+
host.clear()
259+
260+
expect(host.lastModified.size).toBe(0)
261+
})
262+
263+
it('should allow filtering entries by version', () => {
264+
const host = createHost()
265+
host.add({ id: 'f1', message: 'a', level: 'info' })
266+
const versionAfterFirst = (host as any)._clock as number
267+
268+
host.add({ id: 'f2', message: 'b', level: 'info' })
269+
host.update('f1', { message: 'a updated' })
270+
271+
// Entries modified after versionAfterFirst
272+
const modified: string[] = []
273+
for (const [id] of host.entries) {
274+
const mod = host.lastModified.get(id)
275+
if (mod != null && mod > versionAfterFirst)
276+
modified.push(id)
277+
}
278+
279+
// Both f2 (added after) and f1 (updated after) should be included
280+
expect(modified).toContain('f1')
281+
expect(modified).toContain('f2')
282+
})
283+
284+
it('should allow filtering removals by version', () => {
285+
const host = createHost()
286+
host.add({ id: 'r1', message: 'a', level: 'info' })
287+
host.add({ id: 'r2', message: 'b', level: 'info' })
288+
host.remove('r1')
289+
const versionAfterRemove = (host as any)._clock as number
290+
291+
host.add({ id: 'r3', message: 'c', level: 'info' })
292+
host.remove('r2')
293+
294+
// Removals after versionAfterRemove
295+
const removedSince = host.removals
296+
.filter(r => r.time > versionAfterRemove)
297+
.map(r => r.id)
298+
299+
expect(removedSince).toEqual(['r2'])
300+
})
301+
})
302+
})

0 commit comments

Comments
 (0)