@barefootjs/jsx 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/compiler.d.ts.map +1 -1
- package/dist/debug-profile.d.ts +115 -0
- package/dist/debug-profile.d.ts.map +1 -0
- package/dist/debug.d.ts +4 -3
- package/dist/debug.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +31 -0
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1905 -207
- package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +6 -0
- package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts +2 -2
- package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts +3 -3
- package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts +2 -0
- package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +2 -0
- package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts +4 -2
- package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +3 -1
- package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +7 -0
- package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +7 -0
- package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/insert.d.ts +8 -0
- package/dist/ir-to-client-js/control-flow/plan/insert.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +8 -0
- package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +28 -0
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
- package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/component-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/composite-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/event-listener.d.ts +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/event-listener.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/insert.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +2 -2
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/index.d.ts +2 -2
- package/dist/ir-to-client-js/index.d.ts.map +1 -1
- package/dist/ir-to-client-js/phases/effects-and-on-mounts.d.ts.map +1 -1
- package/dist/ir-to-client-js/phases/event-handlers.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/build-declaration-emit.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts +6 -0
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +5 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +29 -0
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/loop-destructure.d.ts +26 -0
- package/dist/loop-destructure.d.ts.map +1 -0
- package/dist/profiler.d.ts +492 -0
- package/dist/profiler.d.ts.map +1 -0
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/debug-profile.test.ts +405 -0
- package/src/__tests__/expression-parser.test.ts +44 -1
- package/src/__tests__/profile-bfid-emission.test.ts +63 -0
- package/src/__tests__/profile-binding-ids.test.ts +123 -0
- package/src/__tests__/profile-cond-binding-ids.test.ts +80 -0
- package/src/__tests__/profile-loop-binding-ids.test.ts +106 -0
- package/src/__tests__/profile-nested-binding-ids.test.ts +153 -0
- package/src/__tests__/profile-turn-markers-branch.test.ts +83 -0
- package/src/__tests__/profile-turn-markers-delegation.test.ts +63 -0
- package/src/__tests__/profile-turn-markers.test.ts +54 -0
- package/src/__tests__/profiler-batch-advisor.test.ts +198 -0
- package/src/__tests__/profiler-coverage-conformance.test.ts +360 -0
- package/src/__tests__/profiler-e2e.test.ts +104 -0
- package/src/__tests__/profiler-hot-subscribers.test.ts +263 -0
- package/src/__tests__/profiler-wasted-re-runs.test.ts +147 -0
- package/src/__tests__/profiler.test.ts +408 -0
- package/src/__tests__/ssr-defaults.test.ts +24 -0
- package/src/compiler.ts +3 -0
- package/src/debug-profile.ts +543 -0
- package/src/debug.ts +192 -28
- package/src/expression-parser.ts +53 -0
- package/src/index.ts +72 -1
- package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +6 -0
- package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +5 -3
- package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +3 -1
- package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +8 -2
- package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +19 -3
- package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +2 -0
- package/src/ir-to-client-js/control-flow/plan/build-insert.ts +9 -2
- package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +9 -1
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +12 -8
- package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +10 -4
- package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +7 -0
- package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +7 -0
- package/src/ir-to-client-js/control-flow/plan/insert.ts +8 -0
- package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +8 -0
- package/src/ir-to-client-js/control-flow/plan/loop.ts +28 -0
- package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
- package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +5 -3
- package/src/ir-to-client-js/control-flow/stringify/component-loop.ts +4 -2
- package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +6 -3
- package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +14 -2
- package/src/ir-to-client-js/control-flow/stringify/event-listener.ts +5 -2
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +13 -11
- package/src/ir-to-client-js/control-flow/stringify/insert.ts +19 -7
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +18 -13
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +9 -7
- package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +18 -14
- package/src/ir-to-client-js/control-flow.ts +12 -6
- package/src/ir-to-client-js/emit-reactive.ts +18 -5
- package/src/ir-to-client-js/imports.ts +2 -0
- package/src/ir-to-client-js/index.ts +6 -1
- package/src/ir-to-client-js/phases/effects-and-on-mounts.ts +10 -4
- package/src/ir-to-client-js/phases/event-handlers.ts +6 -2
- package/src/ir-to-client-js/plan/build-declaration-emit.ts +7 -1
- package/src/ir-to-client-js/plan/declaration-emit.ts +6 -0
- package/src/ir-to-client-js/stringify/declaration-emit.ts +12 -6
- package/src/ir-to-client-js/types.ts +5 -0
- package/src/ir-to-client-js/utils.ts +37 -0
- package/src/jsx-to-ir.ts +2 -2
- package/src/loop-destructure.ts +170 -0
- package/src/profiler.ts +1488 -0
- package/src/ssr-defaults.ts +65 -0
- package/src/types.ts +8 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hot subscribers analysis (#1690, §4.2.1).
|
|
3
|
+
*
|
|
4
|
+
* Pure over the SR2 event stream + SR4 id index: ranks effects/memos by total
|
|
5
|
+
* run time, surfaces re-run pressure (`runsPerTurn`), and joins each to source
|
|
6
|
+
* loc. Deterministic — same stream ⇒ same ranking.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test, expect } from 'bun:test'
|
|
10
|
+
import { analyzeHotSubscribers, buildIdIndex, formatHotSubscribers } from '../profiler'
|
|
11
|
+
import { buildComponentAnalysis } from '../debug'
|
|
12
|
+
import type { ProfilerEvent } from '@barefootjs/shared'
|
|
13
|
+
|
|
14
|
+
const source = `
|
|
15
|
+
'use client'
|
|
16
|
+
import { createSignal, createMemo, createEffect } from '@barefootjs/client'
|
|
17
|
+
|
|
18
|
+
export function Calc() {
|
|
19
|
+
const [count, setCount] = createSignal(0)
|
|
20
|
+
const a = createMemo(() => count() * 2)
|
|
21
|
+
createEffect(() => console.log(a()))
|
|
22
|
+
return <button onClick={() => setCount(n => n + 1)}>{a()}</button>
|
|
23
|
+
}
|
|
24
|
+
`
|
|
25
|
+
|
|
26
|
+
const { graph } = buildComponentAnalysis(source, 'Calc.tsx')
|
|
27
|
+
const index = buildIdIndex(graph)
|
|
28
|
+
|
|
29
|
+
let seq = 0
|
|
30
|
+
const ev = (type: ProfilerEvent['type'], f: Partial<ProfilerEvent> = {}): ProfilerEvent =>
|
|
31
|
+
({ type, seq: seq++, turn: null, ...f })
|
|
32
|
+
|
|
33
|
+
describe('analyzeHotSubscribers', () => {
|
|
34
|
+
test('ranks by total ms, sums dur, counts runs, and joins loc', () => {
|
|
35
|
+
seq = 0
|
|
36
|
+
const memo = 'Calc#memo:a'
|
|
37
|
+
const events: ProfilerEvent[] = [
|
|
38
|
+
ev('effectEnter', { subscriber: memo, turn: 'Calc#handler:s0:click' }),
|
|
39
|
+
ev('effectExit', { subscriber: memo, dur: 5, turn: 'Calc#handler:s0:click' }),
|
|
40
|
+
ev('effectEnter', { subscriber: memo, turn: 'Calc#handler:s0:click' }),
|
|
41
|
+
ev('effectExit', { subscriber: memo, dur: 7, turn: 'Calc#handler:s0:click' }),
|
|
42
|
+
]
|
|
43
|
+
const r = analyzeHotSubscribers(events, index)
|
|
44
|
+
expect(r.subscribers).toHaveLength(1)
|
|
45
|
+
const top = r.subscribers[0]
|
|
46
|
+
expect(top.subscriber).toBe(memo)
|
|
47
|
+
expect(top.runs).toBe(2)
|
|
48
|
+
expect(top.totalMs).toBe(12)
|
|
49
|
+
expect(top.name).toBe('a')
|
|
50
|
+
expect(top.kind).toBe('memo')
|
|
51
|
+
expect(top.loc!.line).toBeGreaterThan(0)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('runsPerTurn flags re-run pressure within a turn', () => {
|
|
55
|
+
seq = 0
|
|
56
|
+
const id = 'Calc#memo:a'
|
|
57
|
+
// 3 runs, all in the same turn → 3 runs/turn → hot at default threshold 2.
|
|
58
|
+
const events: ProfilerEvent[] = [
|
|
59
|
+
ev('effectEnter', { subscriber: id, turn: 't1' }),
|
|
60
|
+
ev('effectEnter', { subscriber: id, turn: 't1' }),
|
|
61
|
+
ev('effectEnter', { subscriber: id, turn: 't1' }),
|
|
62
|
+
]
|
|
63
|
+
const r = analyzeHotSubscribers(events, index)
|
|
64
|
+
expect(r.subscribers[0].turns).toBe(1)
|
|
65
|
+
expect(r.subscribers[0].runsPerTurn).toBe(3)
|
|
66
|
+
expect(r.subscribers[0].hot).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('mount runs (turn=null) are excluded from runsPerTurn, kept as mountRuns', () => {
|
|
70
|
+
seq = 0
|
|
71
|
+
const id = 'Calc#memo:a'
|
|
72
|
+
// 1 mount run + 5 runs in one interaction turn = the worst case the e2e
|
|
73
|
+
// surfaced: per-turn pressure is 5.0, not (6 runs / 2 buckets) = 3.0.
|
|
74
|
+
const events: ProfilerEvent[] = [
|
|
75
|
+
ev('effectEnter', { subscriber: id }), // mount (turn null)
|
|
76
|
+
ev('effectEnter', { subscriber: id, turn: 't1' }),
|
|
77
|
+
ev('effectEnter', { subscriber: id, turn: 't1' }),
|
|
78
|
+
ev('effectEnter', { subscriber: id, turn: 't1' }),
|
|
79
|
+
ev('effectEnter', { subscriber: id, turn: 't1' }),
|
|
80
|
+
ev('effectEnter', { subscriber: id, turn: 't1' }),
|
|
81
|
+
]
|
|
82
|
+
const top = analyzeHotSubscribers(events, index).subscribers[0]
|
|
83
|
+
expect(top.runs).toBe(6)
|
|
84
|
+
expect(top.mountRuns).toBe(1)
|
|
85
|
+
expect(top.turns).toBe(1)
|
|
86
|
+
expect(top.runsPerTurn).toBe(5)
|
|
87
|
+
expect(top.hot).toBe(true)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('a subscriber that only ran at mount has runsPerTurn 0 (not hot)', () => {
|
|
91
|
+
seq = 0
|
|
92
|
+
const id = 'Calc#memo:a'
|
|
93
|
+
const events: ProfilerEvent[] = [ev('effectEnter', { subscriber: id })]
|
|
94
|
+
const top = analyzeHotSubscribers(events, index).subscribers[0]
|
|
95
|
+
expect(top.mountRuns).toBe(1)
|
|
96
|
+
expect(top.turns).toBe(0)
|
|
97
|
+
expect(top.runsPerTurn).toBe(0)
|
|
98
|
+
expect(top.hot).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('one run per turn across turns is not hot', () => {
|
|
102
|
+
seq = 0
|
|
103
|
+
const id = 'Calc#memo:a'
|
|
104
|
+
const events: ProfilerEvent[] = [
|
|
105
|
+
ev('effectEnter', { subscriber: id, turn: 't1' }),
|
|
106
|
+
ev('effectEnter', { subscriber: id, turn: 't2' }),
|
|
107
|
+
]
|
|
108
|
+
const r = analyzeHotSubscribers(events, index)
|
|
109
|
+
expect(r.subscribers[0].runsPerTurn).toBe(1)
|
|
110
|
+
expect(r.subscribers[0].hot).toBe(false)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('ranks by cost (totalMs) — the most expensive first — and honors topN', () => {
|
|
114
|
+
seq = 0
|
|
115
|
+
// `a` costs more time though it runs fewer times; cost is what to fix.
|
|
116
|
+
const events: ProfilerEvent[] = [
|
|
117
|
+
ev('effectEnter', { subscriber: 'Calc#memo:a' }),
|
|
118
|
+
ev('effectExit', { subscriber: 'Calc#memo:a', dur: 99 }), // expensive, 1 run
|
|
119
|
+
ev('effectEnter', { subscriber: 'Calc#memo:b' }),
|
|
120
|
+
ev('effectEnter', { subscriber: 'Calc#memo:b' }),
|
|
121
|
+
ev('effectExit', { subscriber: 'Calc#memo:b', dur: 1 }), // cheap, 2 runs
|
|
122
|
+
]
|
|
123
|
+
const r = analyzeHotSubscribers(events, index, { topN: 1 })
|
|
124
|
+
expect(r.subscribers).toHaveLength(1)
|
|
125
|
+
expect(r.subscribers[0].subscriber).toBe('Calc#memo:a') // 99ms > 1ms
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('minMs drops sub-threshold subscribers (--hot-ms noise floor)', () => {
|
|
129
|
+
seq = 0
|
|
130
|
+
// One costly subscriber and a long tail of cheap ones — `--hot-ms` keeps
|
|
131
|
+
// only what is worth a fix.
|
|
132
|
+
const events: ProfilerEvent[] = [
|
|
133
|
+
ev('effectEnter', { subscriber: 'Calc#memo:a' }),
|
|
134
|
+
ev('effectExit', { subscriber: 'Calc#memo:a', dur: 5 }),
|
|
135
|
+
ev('effectEnter', { subscriber: 'Calc#memo:b' }),
|
|
136
|
+
ev('effectExit', { subscriber: 'Calc#memo:b', dur: 0.04 }), // rounds to 0.0ms
|
|
137
|
+
]
|
|
138
|
+
const r = analyzeHotSubscribers(events, index, { minMs: 1 })
|
|
139
|
+
expect(r.subscribers).toHaveLength(1)
|
|
140
|
+
expect(r.subscribers[0].subscriber).toBe('Calc#memo:a')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('minMs and topN compose (filter floor, then cap)', () => {
|
|
144
|
+
seq = 0
|
|
145
|
+
const events: ProfilerEvent[] = [
|
|
146
|
+
ev('effectEnter', { subscriber: 'Calc#memo:a' }),
|
|
147
|
+
ev('effectExit', { subscriber: 'Calc#memo:a', dur: 10 }),
|
|
148
|
+
ev('effectEnter', { subscriber: 'Calc#memo:b' }),
|
|
149
|
+
ev('effectExit', { subscriber: 'Calc#memo:b', dur: 5 }),
|
|
150
|
+
ev('effectEnter', { subscriber: 'Calc#memo:c' }),
|
|
151
|
+
ev('effectExit', { subscriber: 'Calc#memo:c', dur: 0.01 }), // below floor
|
|
152
|
+
]
|
|
153
|
+
const r = analyzeHotSubscribers(events, index, { minMs: 1, topN: 1 })
|
|
154
|
+
expect(r.subscribers).toHaveLength(1)
|
|
155
|
+
expect(r.subscribers[0].subscriber).toBe('Calc#memo:a')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('same-cost subscribers tiebreak by runs then id — stable across timing noise', () => {
|
|
159
|
+
seq = 0
|
|
160
|
+
// Both round to the same displayed 0.1ms cost; runs (equal) then id decide.
|
|
161
|
+
const mk = (durA: number, durB: number): ProfilerEvent[] => {
|
|
162
|
+
seq = 0
|
|
163
|
+
return [
|
|
164
|
+
ev('effectEnter', { subscriber: 'Calc#memo:b' }),
|
|
165
|
+
ev('effectExit', { subscriber: 'Calc#memo:b', dur: durB }),
|
|
166
|
+
ev('effectEnter', { subscriber: 'Calc#memo:a' }),
|
|
167
|
+
ev('effectExit', { subscriber: 'Calc#memo:a', dur: durA }),
|
|
168
|
+
]
|
|
169
|
+
}
|
|
170
|
+
// 0.41 and 0.44 both round to 0.4ms → same cohort → id breaks the tie.
|
|
171
|
+
const order1 = analyzeHotSubscribers(mk(0.41, 0.44), index).subscribers.map(s => s.subscriber)
|
|
172
|
+
const order2 = analyzeHotSubscribers(mk(0.44, 0.41), index).subscribers.map(s => s.subscriber)
|
|
173
|
+
expect(order1).toEqual(order2) // timing flip within a cost bucket does not change rank
|
|
174
|
+
expect(order1).toEqual(['Calc#memo:a', 'Calc#memo:b']) // id-sorted
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('unresolved subscriber ids surface as coverage gaps, events not dropped', () => {
|
|
178
|
+
seq = 0
|
|
179
|
+
const ghost = 'Calc#effect:does-not-exist'
|
|
180
|
+
const events: ProfilerEvent[] = [
|
|
181
|
+
ev('effectEnter', { subscriber: ghost }),
|
|
182
|
+
ev('effectExit', { subscriber: ghost, dur: 3 }),
|
|
183
|
+
]
|
|
184
|
+
const r = analyzeHotSubscribers(events, index)
|
|
185
|
+
expect(r.subscribers[0].loc).toBeUndefined()
|
|
186
|
+
expect(r.subscribers[0].runs).toBe(1)
|
|
187
|
+
expect(r.unattributed.map(u => u.id)).toContain(ghost)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('an uninstrumented `e<n>` id stays in the table, labelled with candidates (#1849 B6)', () => {
|
|
191
|
+
seq = 0
|
|
192
|
+
// `e1` is the runtime fallback id for a createEffect with no compiler
|
|
193
|
+
// __bfId (one inside a ref callback — accordion/tabs/toast). Its cost is
|
|
194
|
+
// real, so it must NOT be hidden; instead it is marked `uninstrumented` and
|
|
195
|
+
// carries candidate source lines so the reader can jump to the likely call.
|
|
196
|
+
const events: ProfilerEvent[] = [
|
|
197
|
+
ev('effectEnter', { subscriber: 'e1' }),
|
|
198
|
+
ev('effectExit', { subscriber: 'e1', dur: 5 }),
|
|
199
|
+
ev('effectEnter', { subscriber: 'Calc#memo:a' }),
|
|
200
|
+
ev('effectExit', { subscriber: 'Calc#memo:a', dur: 2 }),
|
|
201
|
+
]
|
|
202
|
+
const candidates = [
|
|
203
|
+
{ file: 'collapsible/index.tsx', line: 82 },
|
|
204
|
+
{ file: 'collapsible/index.tsx', line: 126 },
|
|
205
|
+
]
|
|
206
|
+
const r = analyzeHotSubscribers(events, index, { uninstrumentedCandidates: candidates })
|
|
207
|
+
const e1 = r.subscribers.find(s => s.subscriber === 'e1')
|
|
208
|
+
expect(e1).toBeDefined() // kept, not filtered
|
|
209
|
+
expect(e1!.resolution).toBe('uninstrumented')
|
|
210
|
+
expect(e1!.candidates).toEqual(candidates)
|
|
211
|
+
// A real compiler id is unaffected — no uninstrumented marker.
|
|
212
|
+
const memo = r.subscribers.find(s => s.subscriber === 'Calc#memo:a')!
|
|
213
|
+
expect(memo.resolution).toBeUndefined()
|
|
214
|
+
|
|
215
|
+
// The formatter relabels the location and lists the candidate lines (the
|
|
216
|
+
// `e1` id still names the row in the label column).
|
|
217
|
+
const out = formatHotSubscribers(r)
|
|
218
|
+
expect(out).toContain('(uninstrumented — createEffect in non-JSX scope)')
|
|
219
|
+
expect(out).toContain('candidates: collapsible/index.tsx:82, :126')
|
|
220
|
+
// Exactly one pair of parens around the label — the `where` string and the
|
|
221
|
+
// row formatter must not both add them (regression: `((uninstrumented …))`).
|
|
222
|
+
expect(out).not.toContain('((')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('caps the formatted list and summarizes the overflow', () => {
|
|
226
|
+
seq = 0
|
|
227
|
+
const events: ProfilerEvent[] = []
|
|
228
|
+
for (let i = 0; i < 30; i++) {
|
|
229
|
+
events.push(ev('effectEnter', { subscriber: `X#effect:${i}` }))
|
|
230
|
+
events.push(ev('effectExit', { subscriber: `X#effect:${i}`, dur: 30 - i }))
|
|
231
|
+
}
|
|
232
|
+
const out = formatHotSubscribers(analyzeHotSubscribers(events, index), 12)
|
|
233
|
+
// 12 shown + the "… and N more" summary line.
|
|
234
|
+
expect(out).toContain('… and 18 more')
|
|
235
|
+
expect((out.match(/\d+×/g) ?? []).length).toBe(12)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('formats a readable report with a hot note', () => {
|
|
239
|
+
seq = 0
|
|
240
|
+
const events: ProfilerEvent[] = [
|
|
241
|
+
ev('effectEnter', { subscriber: 'Calc#memo:a', turn: 't1' }),
|
|
242
|
+
ev('effectEnter', { subscriber: 'Calc#memo:a', turn: 't1' }),
|
|
243
|
+
ev('effectExit', { subscriber: 'Calc#memo:a', dur: 4 }),
|
|
244
|
+
]
|
|
245
|
+
const out = formatHotSubscribers(analyzeHotSubscribers(events, index))
|
|
246
|
+
expect(out).toContain('hot subscribers')
|
|
247
|
+
expect(out).toMatch(/⚠ [\d.]+\/turn/) // hot note
|
|
248
|
+
expect(out).toContain('█') // proportional bar
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('bars are proportional to cost (totalMs) — the costliest is the longest', () => {
|
|
252
|
+
seq = 0
|
|
253
|
+
const events: ProfilerEvent[] = [
|
|
254
|
+
ev('effectEnter', { subscriber: 'Calc#memo:a' }),
|
|
255
|
+
ev('effectExit', { subscriber: 'Calc#memo:a', dur: 8 }), // costly → ranked first, full bar
|
|
256
|
+
ev('effectEnter', { subscriber: 'Calc#memo:b' }),
|
|
257
|
+
ev('effectExit', { subscriber: 'Calc#memo:b', dur: 1 }), // cheap → short bar
|
|
258
|
+
]
|
|
259
|
+
const lines = formatHotSubscribers(analyzeHotSubscribers(events, index)).split('\n').filter(l => l.includes('█'))
|
|
260
|
+
const barLen = (l: string) => (l.match(/█/g) ?? []).length
|
|
261
|
+
expect(barLen(lines[0])).toBeGreaterThan(barLen(lines[1]))
|
|
262
|
+
})
|
|
263
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wasted re-runs analysis (#1690, §4.2.2).
|
|
3
|
+
*
|
|
4
|
+
* Pure over the SR2 stream's `effectOutput` fingerprints + SR4 id index: counts
|
|
5
|
+
* runs that recomputed but produced output identical to the previous run, ranks
|
|
6
|
+
* by removable cost, and joins each to source loc. Deterministic — same stream ⇒
|
|
7
|
+
* same ranking.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, test, expect } from 'bun:test'
|
|
11
|
+
import { analyzeWastedReReruns, buildIdIndex, formatWastedReReruns } from '../profiler'
|
|
12
|
+
import { buildComponentAnalysis } from '../debug'
|
|
13
|
+
import type { ProfilerEvent } from '@barefootjs/shared'
|
|
14
|
+
|
|
15
|
+
const source = `
|
|
16
|
+
'use client'
|
|
17
|
+
import { createSignal, createMemo, createEffect } from '@barefootjs/client'
|
|
18
|
+
|
|
19
|
+
export function Calc() {
|
|
20
|
+
const [count, setCount] = createSignal(0)
|
|
21
|
+
const a = createMemo(() => count() * 2)
|
|
22
|
+
createEffect(() => console.log(a()))
|
|
23
|
+
return <button onClick={() => setCount(n => n + 1)}>{a()}</button>
|
|
24
|
+
}
|
|
25
|
+
`
|
|
26
|
+
|
|
27
|
+
const { graph } = buildComponentAnalysis(source, 'Calc.tsx')
|
|
28
|
+
const index = buildIdIndex(graph)
|
|
29
|
+
|
|
30
|
+
let seq = 0
|
|
31
|
+
const ev = (type: ProfilerEvent['type'], f: Partial<ProfilerEvent> = {}): ProfilerEvent =>
|
|
32
|
+
({ type, seq: seq++, turn: null, ...f })
|
|
33
|
+
|
|
34
|
+
const out = (subscriber: string, changed: boolean, turn: string | null = null): ProfilerEvent =>
|
|
35
|
+
ev('effectOutput', { subscriber, changed, turn })
|
|
36
|
+
|
|
37
|
+
describe('analyzeWastedReReruns', () => {
|
|
38
|
+
test('counts identical-output runs, computes ratio, and joins loc', () => {
|
|
39
|
+
seq = 0
|
|
40
|
+
const memo = 'Calc#memo:a'
|
|
41
|
+
// 4 runs: first changed (real output), 3 identical → 3/4 wasted.
|
|
42
|
+
const events: ProfilerEvent[] = [
|
|
43
|
+
out(memo, true),
|
|
44
|
+
out(memo, false),
|
|
45
|
+
out(memo, false),
|
|
46
|
+
out(memo, false),
|
|
47
|
+
]
|
|
48
|
+
const r = analyzeWastedReReruns(events, index)
|
|
49
|
+
expect(r.subscribers).toHaveLength(1)
|
|
50
|
+
const top = r.subscribers[0]
|
|
51
|
+
expect(top.subscriber).toBe(memo)
|
|
52
|
+
expect(top.totalRuns).toBe(4)
|
|
53
|
+
expect(top.wastedRuns).toBe(3)
|
|
54
|
+
expect(top.wastedRatio).toBeCloseTo(0.75)
|
|
55
|
+
expect(top.name).toBe('a')
|
|
56
|
+
expect(top.kind).toBe('memo')
|
|
57
|
+
expect(top.loc!.line).toBeGreaterThan(0)
|
|
58
|
+
expect(top.wasted).toBe(true) // 0.75 ≥ default 0.5
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('subscribers with zero wasted runs are not findings', () => {
|
|
62
|
+
seq = 0
|
|
63
|
+
const events: ProfilerEvent[] = [out('Calc#memo:a', true), out('Calc#memo:a', true)]
|
|
64
|
+
const r = analyzeWastedReReruns(events, index)
|
|
65
|
+
expect(r.subscribers).toHaveLength(0)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('wastedRatio threshold flags only subscribers at/above it', () => {
|
|
69
|
+
seq = 0
|
|
70
|
+
// a: 1/4 wasted (0.25); b: 3/4 wasted (0.75). At threshold 0.5 only b is flagged.
|
|
71
|
+
const events: ProfilerEvent[] = [
|
|
72
|
+
out('Calc#memo:a', true), out('Calc#memo:a', true), out('Calc#memo:a', true), out('Calc#memo:a', false),
|
|
73
|
+
out('Calc#memo:b', false), out('Calc#memo:b', false), out('Calc#memo:b', false), out('Calc#memo:b', true),
|
|
74
|
+
]
|
|
75
|
+
const r = analyzeWastedReReruns(events, index, { wastedRatio: 0.5 })
|
|
76
|
+
const flagged = r.subscribers.filter(s => s.wasted).map(s => s.subscriber)
|
|
77
|
+
expect(flagged).toEqual(['Calc#memo:b'])
|
|
78
|
+
// both still appear as findings (each has ≥1 wasted run)
|
|
79
|
+
expect(r.subscribers.map(s => s.subscriber).sort()).toEqual(['Calc#memo:a', 'Calc#memo:b'])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('ranks by removable cost (wastedRuns), then ratio, then id — deterministic', () => {
|
|
83
|
+
seq = 0
|
|
84
|
+
// a: 2 wasted of 10 (0.2). b: 5 wasted of 10 (0.5). c: 5 wasted of 6 (~0.83).
|
|
85
|
+
const mk = (id: string, wasted: number, total: number): ProfilerEvent[] => {
|
|
86
|
+
const es: ProfilerEvent[] = []
|
|
87
|
+
for (let i = 0; i < total; i++) es.push(out(id, i >= wasted))
|
|
88
|
+
return es
|
|
89
|
+
}
|
|
90
|
+
const events = [...mk('Calc#memo:a', 2, 10), ...mk('Calc#memo:b', 5, 10), ...mk('Calc#memo:c', 5, 6)]
|
|
91
|
+
const order = analyzeWastedReReruns(events, index).subscribers.map(s => s.subscriber)
|
|
92
|
+
// b (5 wasted, 0.5) and c (5 wasted, 0.83) tie on wastedRuns → ratio breaks it → c first.
|
|
93
|
+
expect(order).toEqual(['Calc#memo:c', 'Calc#memo:b', 'Calc#memo:a'])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('honors topN after ranking', () => {
|
|
97
|
+
seq = 0
|
|
98
|
+
const events: ProfilerEvent[] = [
|
|
99
|
+
out('Calc#memo:a', false), out('Calc#memo:a', false), // 2 wasted
|
|
100
|
+
out('Calc#memo:b', false), // 1 wasted
|
|
101
|
+
]
|
|
102
|
+
const r = analyzeWastedReReruns(events, index, { topN: 1 })
|
|
103
|
+
expect(r.subscribers).toHaveLength(1)
|
|
104
|
+
expect(r.subscribers[0].subscriber).toBe('Calc#memo:a')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('runs without a fingerprint (no effectOutput) are not counted', () => {
|
|
108
|
+
seq = 0
|
|
109
|
+
// effectEnter/Exit without effectOutput → the run is unfingerprintable, ignored.
|
|
110
|
+
const events: ProfilerEvent[] = [
|
|
111
|
+
ev('effectEnter', { subscriber: 'Calc#memo:a' }),
|
|
112
|
+
ev('effectExit', { subscriber: 'Calc#memo:a', dur: 3 }),
|
|
113
|
+
out('Calc#memo:a', false),
|
|
114
|
+
]
|
|
115
|
+
const r = analyzeWastedReReruns(events, index)
|
|
116
|
+
expect(r.subscribers[0].totalRuns).toBe(1) // only the one effectOutput
|
|
117
|
+
expect(r.subscribers[0].wastedRuns).toBe(1)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('unresolved subscriber ids surface as coverage gaps, runs still counted', () => {
|
|
121
|
+
seq = 0
|
|
122
|
+
const ghost = 'Calc#effect:does-not-exist'
|
|
123
|
+
const events: ProfilerEvent[] = [out(ghost, false), out(ghost, false)]
|
|
124
|
+
const r = analyzeWastedReReruns(events, index)
|
|
125
|
+
expect(r.subscribers[0].loc).toBeUndefined()
|
|
126
|
+
expect(r.subscribers[0].wastedRuns).toBe(2)
|
|
127
|
+
expect(r.unattributed.map(u => u.id)).toContain(ghost)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('formats the priceLabel-style finding with a proportional bar and fix hint', () => {
|
|
131
|
+
seq = 0
|
|
132
|
+
const events: ProfilerEvent[] = []
|
|
133
|
+
events.push(out('Calc#memo:a', true))
|
|
134
|
+
for (let i = 0; i < 150; i++) events.push(out('Calc#memo:a', false))
|
|
135
|
+
const txt = formatWastedReReruns(analyzeWastedReReruns(events, index))
|
|
136
|
+
expect(txt).toContain('wasted re-runs')
|
|
137
|
+
expect(txt).toMatch(/wasted: 150\/151 produced identical value/)
|
|
138
|
+
expect(txt).toContain('█') // proportional bar
|
|
139
|
+
expect(txt).toContain('split so it doesn') // fix hint on a flagged subscriber
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('empty stream formats a clean no-op report', () => {
|
|
143
|
+
seq = 0
|
|
144
|
+
const txt = formatWastedReReruns(analyzeWastedReReruns([], index))
|
|
145
|
+
expect(txt).toContain('(no wasted re-runs recorded)')
|
|
146
|
+
})
|
|
147
|
+
})
|