@barefootjs/jsx 0.10.1 → 0.12.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.
Files changed (137) hide show
  1. package/dist/compiler.d.ts.map +1 -1
  2. package/dist/debug-profile.d.ts +115 -0
  3. package/dist/debug-profile.d.ts.map +1 -0
  4. package/dist/debug.d.ts +4 -3
  5. package/dist/debug.d.ts.map +1 -1
  6. package/dist/expression-parser.d.ts +31 -0
  7. package/dist/expression-parser.d.ts.map +1 -1
  8. package/dist/index.d.ts +8 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1881 -207
  11. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +6 -0
  12. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts +2 -2
  18. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts +3 -3
  20. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts +2 -0
  23. package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +2 -0
  25. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts +4 -2
  27. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +3 -1
  29. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +7 -0
  31. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +7 -0
  33. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
  34. package/dist/ir-to-client-js/control-flow/plan/insert.d.ts +8 -0
  35. package/dist/ir-to-client-js/control-flow/plan/insert.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +8 -0
  37. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  38. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +28 -0
  39. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  40. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
  41. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/control-flow/stringify/component-loop.d.ts.map +1 -1
  43. package/dist/ir-to-client-js/control-flow/stringify/composite-loop.d.ts.map +1 -1
  44. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  45. package/dist/ir-to-client-js/control-flow/stringify/event-listener.d.ts +1 -1
  46. package/dist/ir-to-client-js/control-flow/stringify/event-listener.d.ts.map +1 -1
  47. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +1 -1
  48. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  49. package/dist/ir-to-client-js/control-flow/stringify/insert.d.ts.map +1 -1
  50. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +2 -2
  51. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  52. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  53. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  54. package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
  55. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  56. package/dist/ir-to-client-js/imports.d.ts +2 -2
  57. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  58. package/dist/ir-to-client-js/index.d.ts +2 -2
  59. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  60. package/dist/ir-to-client-js/phases/effects-and-on-mounts.d.ts.map +1 -1
  61. package/dist/ir-to-client-js/phases/event-handlers.d.ts.map +1 -1
  62. package/dist/ir-to-client-js/plan/build-declaration-emit.d.ts.map +1 -1
  63. package/dist/ir-to-client-js/plan/declaration-emit.d.ts +6 -0
  64. package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
  65. package/dist/ir-to-client-js/types.d.ts +5 -0
  66. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  67. package/dist/ir-to-client-js/utils.d.ts +29 -0
  68. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  69. package/dist/loop-destructure.d.ts +26 -0
  70. package/dist/loop-destructure.d.ts.map +1 -0
  71. package/dist/profiler.d.ts +502 -0
  72. package/dist/profiler.d.ts.map +1 -0
  73. package/dist/types.d.ts +8 -0
  74. package/dist/types.d.ts.map +1 -1
  75. package/package.json +2 -2
  76. package/src/__tests__/debug-profile.test.ts +405 -0
  77. package/src/__tests__/expression-parser.test.ts +44 -1
  78. package/src/__tests__/profile-bfid-emission.test.ts +63 -0
  79. package/src/__tests__/profile-binding-ids.test.ts +123 -0
  80. package/src/__tests__/profile-cond-binding-ids.test.ts +80 -0
  81. package/src/__tests__/profile-loop-binding-ids.test.ts +106 -0
  82. package/src/__tests__/profile-nested-binding-ids.test.ts +153 -0
  83. package/src/__tests__/profile-turn-markers-branch.test.ts +83 -0
  84. package/src/__tests__/profile-turn-markers-delegation.test.ts +63 -0
  85. package/src/__tests__/profile-turn-markers.test.ts +54 -0
  86. package/src/__tests__/profiler-batch-advisor.test.ts +198 -0
  87. package/src/__tests__/profiler-coverage-conformance.test.ts +360 -0
  88. package/src/__tests__/profiler-e2e.test.ts +104 -0
  89. package/src/__tests__/profiler-hot-subscribers.test.ts +263 -0
  90. package/src/__tests__/profiler-wasted-re-runs.test.ts +147 -0
  91. package/src/__tests__/profiler.test.ts +466 -0
  92. package/src/compiler.ts +3 -0
  93. package/src/debug-profile.ts +543 -0
  94. package/src/debug.ts +192 -28
  95. package/src/expression-parser.ts +53 -0
  96. package/src/index.ts +72 -1
  97. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +6 -0
  98. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +5 -3
  99. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +3 -1
  100. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +8 -2
  101. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +19 -3
  102. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +2 -0
  103. package/src/ir-to-client-js/control-flow/plan/build-insert.ts +9 -2
  104. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +9 -1
  105. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +12 -8
  106. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +10 -4
  107. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +7 -0
  108. package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +7 -0
  109. package/src/ir-to-client-js/control-flow/plan/insert.ts +8 -0
  110. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +8 -0
  111. package/src/ir-to-client-js/control-flow/plan/loop.ts +28 -0
  112. package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
  113. package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +5 -3
  114. package/src/ir-to-client-js/control-flow/stringify/component-loop.ts +4 -2
  115. package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +6 -3
  116. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +14 -2
  117. package/src/ir-to-client-js/control-flow/stringify/event-listener.ts +5 -2
  118. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +13 -11
  119. package/src/ir-to-client-js/control-flow/stringify/insert.ts +19 -7
  120. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +18 -13
  121. package/src/ir-to-client-js/control-flow/stringify/loop.ts +9 -7
  122. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +18 -14
  123. package/src/ir-to-client-js/control-flow.ts +12 -6
  124. package/src/ir-to-client-js/emit-reactive.ts +18 -5
  125. package/src/ir-to-client-js/imports.ts +2 -0
  126. package/src/ir-to-client-js/index.ts +6 -1
  127. package/src/ir-to-client-js/phases/effects-and-on-mounts.ts +10 -4
  128. package/src/ir-to-client-js/phases/event-handlers.ts +6 -2
  129. package/src/ir-to-client-js/plan/build-declaration-emit.ts +7 -1
  130. package/src/ir-to-client-js/plan/declaration-emit.ts +6 -0
  131. package/src/ir-to-client-js/stringify/declaration-emit.ts +12 -6
  132. package/src/ir-to-client-js/types.ts +5 -0
  133. package/src/ir-to-client-js/utils.ts +37 -0
  134. package/src/jsx-to-ir.ts +2 -2
  135. package/src/loop-destructure.ts +170 -0
  136. package/src/profiler.ts +1520 -0
  137. package/src/types.ts +8 -0
@@ -0,0 +1,466 @@
1
+ /**
2
+ * Reactive performance profiler tests (#1690).
3
+ *
4
+ * Covers the run-free static analysis (SR5 budget, SR6 compile-diff), the SR4
5
+ * id parse/join, and `buildProfileReport` — the dynamic report assembled from a
6
+ * recorded SR2 event stream (the stream itself is produced by the CLI scenario
7
+ * driver, tested separately).
8
+ */
9
+
10
+ import { describe, test, expect } from 'bun:test'
11
+ import {
12
+ buildStaticBudget,
13
+ diffStaticBudget,
14
+ formatStaticBudget,
15
+ formatBudgetDiff,
16
+ buildProfileReport,
17
+ formatProfileReport,
18
+ parseProfilerId,
19
+ buildIdIndex,
20
+ joinProfilerEvents,
21
+ findUninstrumentedEffects,
22
+ } from '../profiler'
23
+ import { buildComponentAnalysis } from '../debug'
24
+ import type { ProfilerEvent } from '@barefootjs/shared'
25
+
26
+ const memoChainSource = `
27
+ 'use client'
28
+ import { createSignal, createMemo, createEffect } from '@barefootjs/client'
29
+
30
+ export function Calc() {
31
+ const [count, setCount] = createSignal(0)
32
+ const a = createMemo(() => count() * 2)
33
+ const b = createMemo(() => a() + 1)
34
+ const c = createMemo(() => b() + 1)
35
+ createEffect(() => console.log(c()))
36
+ return <button onClick={() => setCount(n => n + 1)}>{c()}</button>
37
+ }
38
+ `
39
+
40
+ const counterSource = `
41
+ 'use client'
42
+ import { createSignal } from '@barefootjs/client'
43
+
44
+ export function Counter() {
45
+ const [count, setCount] = createSignal(0)
46
+ return <button onClick={() => setCount(n => n + 1)}>Count: {count()}</button>
47
+ }
48
+ `
49
+
50
+ describe('buildStaticBudget (SR5)', () => {
51
+ test('counts reactive nodes and subscriptions', () => {
52
+ const b = buildStaticBudget(counterSource, 'Counter.tsx', 'Counter')
53
+ expect(b.kind).toBe('static-budget')
54
+ expect(b.componentName).toBe('Counter')
55
+ expect(b.signals).toBe(1)
56
+ expect(b.memos).toBe(0)
57
+ // The text binding `{count()}` subscribes to `count`.
58
+ expect(b.subscriptions).toBeGreaterThan(0)
59
+ expect(b.memoChainDepth).toBe(0)
60
+ })
61
+
62
+ test('excludes event handlers from fan-out and subscriptions (they read, do not react)', () => {
63
+ // `count` is read by the text binding (reactive) AND the onClick handler
64
+ // (not reactive — runs outside any effect). Only the binding counts.
65
+ const src = `
66
+ 'use client'
67
+ import { createSignal } from '@barefootjs/client'
68
+ export function C() {
69
+ const [count, setCount] = createSignal(0)
70
+ return <button onClick={() => setCount(count() + 1)}>{count()}</button>
71
+ }
72
+ `
73
+ const b = buildStaticBudget(src, 'C.tsx', 'C')
74
+ const fan = b.fanOut.find(f => f.signal === 'count')!.subscribers
75
+ // The text binding subscribes; the handler does not.
76
+ expect(fan).toBe(1)
77
+ expect(b.subscriptions).toBe(1)
78
+ })
79
+
80
+ test('measures the longest memo chain', () => {
81
+ const b = buildStaticBudget(memoChainSource, 'Calc.tsx', 'Calc')
82
+ expect(b.memos).toBe(3)
83
+ expect(b.memoChainDepth).toBe(3)
84
+ expect(b.memoChainLongest).toEqual(['a', 'b', 'c'])
85
+ })
86
+
87
+ test('reports per-signal fan-out, hottest first', () => {
88
+ const b = buildStaticBudget(memoChainSource, 'Calc.tsx', 'Calc')
89
+ const count = b.fanOut.find(f => f.signal === 'count')
90
+ expect(count).toBeDefined()
91
+ // count fans out through a → b → c → effect/text, i.e. several subscribers.
92
+ expect(count!.subscribers).toBeGreaterThanOrEqual(3)
93
+ // Sorted descending.
94
+ for (let i = 1; i < b.fanOut.length; i++) {
95
+ expect(b.fanOut[i - 1].subscribers).toBeGreaterThanOrEqual(b.fanOut[i].subscribers)
96
+ }
97
+ })
98
+
99
+ test('honors the fan-out threshold for the hot flag', () => {
100
+ const hot = buildStaticBudget(memoChainSource, 'Calc.tsx', 'Calc', { fanOutThreshold: 1 })
101
+ expect(hot.fanOut.find(f => f.signal === 'count')!.hot).toBe(true)
102
+ const cold = buildStaticBudget(memoChainSource, 'Calc.tsx', 'Calc', { fanOutThreshold: 999 })
103
+ expect(cold.fanOut.every(f => f.hot === false)).toBe(true)
104
+ })
105
+
106
+ test('splits fan-out into direct vs via-memo and keys `hot` off direct', () => {
107
+ // count → a → b → c → {effect, text}: exactly ONE direct subscriber (memo
108
+ // a); the rest are reached only through memo barriers.
109
+ const b = buildStaticBudget(memoChainSource, 'Calc.tsx', 'Calc')
110
+ const count = b.fanOut.find(f => f.signal === 'count')!
111
+ expect(count.direct).toBe(1)
112
+ expect(count.subscribers).toBeGreaterThanOrEqual(4)
113
+ // The transitive total clears a threshold of 3, but direct (1) does not — so
114
+ // the signal is NOT hot. `hot` tracks real per-write pressure, not the total.
115
+ const mid = buildStaticBudget(memoChainSource, 'Calc.tsx', 'Calc', { fanOutThreshold: 3 })
116
+ const c = mid.fanOut.find(f => f.signal === 'count')!
117
+ expect(c.subscribers).toBeGreaterThanOrEqual(3)
118
+ expect(c.hot).toBe(false)
119
+ })
120
+
121
+ test('formats the direct/via-memo split only when a memo barrier routes', () => {
122
+ // Memo chain → the split is shown.
123
+ const withMemo = formatStaticBudget(buildStaticBudget(memoChainSource, 'Calc.tsx', 'Calc'))
124
+ expect(withMemo).toMatch(/count\s+→ \d+ subscribers \(1 direct · \d+ via memo\)/)
125
+ // Counter reads its signal directly (no memo) → no parenthetical.
126
+ const direct = formatStaticBudget(buildStaticBudget(counterSource, 'Counter.tsx', 'Counter'))
127
+ expect(direct).not.toContain('via memo')
128
+ })
129
+
130
+ test('formats a human-readable budget', () => {
131
+ const out = formatStaticBudget(buildStaticBudget(memoChainSource, 'Calc.tsx', 'Calc'))
132
+ expect(out).toContain('static reactivity budget')
133
+ expect(out).toContain('memo-chain depth: 3')
134
+ expect(out).toContain('predictive only')
135
+ })
136
+
137
+ test('flags a compound component whose consumers live in composed children', () => {
138
+ // Reactive state exists but nothing in *this* component reads it — the
139
+ // signal/memo only drive composed child components (the Select/Combobox
140
+ // shape). The single-component budget can't see across that boundary.
141
+ const compound = `
142
+ 'use client'
143
+ import { createSignal, createMemo } from '@barefootjs/client'
144
+ import { Ctx } from './ctx'
145
+ export function Picker(props: { value?: string }) {
146
+ const [internal, setInternal] = createSignal('')
147
+ const isControlled = createMemo(() => props.value !== undefined)
148
+ return <Ctx value={{ internal, setInternal, isControlled }}>{props.children}</Ctx>
149
+ }
150
+ `
151
+ const b = buildStaticBudget(compound, 'Picker.tsx', 'Picker')
152
+ expect(b.signals).toBeGreaterThan(0)
153
+ expect(b.subscriptions).toBe(0)
154
+ expect(b.crossComponentOnly).toBe(true)
155
+ expect(formatStaticBudget(b)).toContain('compound')
156
+ })
157
+
158
+ test('does not flag a self-contained component as compound', () => {
159
+ const b = buildStaticBudget(counterSource, 'Counter.tsx', 'Counter')
160
+ expect(b.crossComponentOnly).toBe(false)
161
+ expect(formatStaticBudget(b)).not.toContain('compound')
162
+ })
163
+
164
+ test('a memo-only component whose memo is consumed in-component is not compound', () => {
165
+ // No signals, so signal `subscriptions`/fan-out are 0 — but the memo IS
166
+ // consumed by an in-component DOM binding, so this is self-contained, not a
167
+ // compound component. The flag must span memo consumers, not just signals.
168
+ const src = `
169
+ 'use client'
170
+ import { createMemo } from '@barefootjs/client'
171
+ export function Label(props: { x?: number }) {
172
+ const label = createMemo(() => (props.x ?? 0) + 1)
173
+ return <div>{label()}</div>
174
+ }
175
+ `
176
+ const b = buildStaticBudget(src, 'Label.tsx', 'Label')
177
+ expect(b.memos).toBe(1)
178
+ expect(b.signals).toBe(0)
179
+ expect(b.subscriptions).toBe(0)
180
+ expect(b.crossComponentOnly).toBe(false)
181
+ })
182
+ })
183
+
184
+ describe('diffStaticBudget (SR6)', () => {
185
+ test('flags an added memo + deeper chain as a regression', () => {
186
+ const base = buildStaticBudget(counterSource, 'Counter.tsx', 'Counter')
187
+ const head = buildStaticBudget(memoChainSource, 'Calc.tsx', 'Calc')
188
+ const diff = diffStaticBudget(base, head)
189
+ expect(diff.memos).toBe(3)
190
+ expect(diff.memoChainDepth).toBe(3)
191
+ expect(diff.regressed).toBe(true)
192
+ })
193
+
194
+ test('reports no regression for identical compiles', () => {
195
+ const a = buildStaticBudget(memoChainSource, 'Calc.tsx', 'Calc')
196
+ const b = buildStaticBudget(memoChainSource, 'Calc.tsx', 'Calc')
197
+ const diff = diffStaticBudget(a, b)
198
+ expect(diff.regressed).toBe(false)
199
+ expect(formatBudgetDiff(diff)).toContain('no structural reactivity change')
200
+ })
201
+
202
+ test('a memo barrier that lowers direct fan-out is not a fan-out regression', () => {
203
+ // Before: `count` is read directly by an effect AND a text binding (direct 2).
204
+ const before = `
205
+ 'use client'
206
+ import { createSignal, createEffect } from '@barefootjs/client'
207
+ export function R() {
208
+ const [count, setCount] = createSignal(0)
209
+ createEffect(() => console.log(count()))
210
+ return <button>{count()}</button>
211
+ }
212
+ `
213
+ // After: both reads go through a memo, so `count`'s direct fan-out drops to 1
214
+ // (the memo) even though a memo was added and the transitive total rose.
215
+ const after = `
216
+ 'use client'
217
+ import { createSignal, createMemo, createEffect } from '@barefootjs/client'
218
+ export function R() {
219
+ const [count, setCount] = createSignal(0)
220
+ const m = createMemo(() => count())
221
+ createEffect(() => console.log(m()))
222
+ return <button>{m()}</button>
223
+ }
224
+ `
225
+ const base = buildStaticBudget(before, 'R.tsx', 'R')
226
+ const head = buildStaticBudget(after, 'R.tsx', 'R')
227
+ expect(base.fanOut.find(f => f.signal === 'count')!.direct).toBe(2)
228
+ expect(head.fanOut.find(f => f.signal === 'count')!.direct).toBe(1)
229
+ // The fan-out delta reads the refactor as the improvement it is: direct
230
+ // fan-out shrank, so the recorded change is a decrease, not growth.
231
+ const diff = diffStaticBudget(base, head)
232
+ const ch = diff.fanOut.find(f => f.signal === 'count')!
233
+ expect(ch.after).toBeLessThan(ch.before)
234
+ })
235
+
236
+ test('carries a "diff" kind discriminator (#1849 B2)', () => {
237
+ // The three JSON modes must be distinguishable: a zero-delta diff
238
+ // (signals: 0 = "no change") must not look like a static budget
239
+ // (signals: 0 = "no signals"). `kind` is the discriminator.
240
+ const a = buildStaticBudget(memoChainSource, 'Calc.tsx', 'Calc')
241
+ const diff = diffStaticBudget(a, a)
242
+ expect(diff.kind).toBe('diff')
243
+ expect(diff.signals).toBe(0)
244
+ })
245
+ })
246
+
247
+ describe('parseProfilerId (SR4)', () => {
248
+ test('splits <Component>#<kind>:<rest>', () => {
249
+ expect(parseProfilerId('Calc#memo:doubled')).toEqual({ component: 'Calc', kind: 'memo', rest: 'doubled' })
250
+ })
251
+ test('keeps the full rest for controlled-effect ids', () => {
252
+ expect(parseProfilerId('Calc#effect:controlled:setCount')).toEqual({
253
+ component: 'Calc', kind: 'effect', rest: 'controlled:setCount',
254
+ })
255
+ })
256
+ test('returns null for non-profiler strings', () => {
257
+ expect(parseProfilerId('not-an-id')).toBeNull()
258
+ expect(parseProfilerId('Calc#memo')).toBeNull()
259
+ })
260
+ })
261
+
262
+ describe('buildIdIndex + joinProfilerEvents (SR4 join)', () => {
263
+ const { graph } = buildComponentAnalysis(memoChainSource, 'Calc.tsx')
264
+ const index = buildIdIndex(graph)
265
+
266
+ const ev = (type: ProfilerEvent['type'], fields: Partial<ProfilerEvent> = {}): ProfilerEvent =>
267
+ ({ type, seq: 0, turn: null, ...fields })
268
+
269
+ test('indexes signals and memos by their compiler id, with source loc', () => {
270
+ const sig = index.get('Calc#signal:count')
271
+ expect(sig).toMatchObject({ kind: 'signal', name: 'count' })
272
+ expect(sig!.loc.line).toBeGreaterThan(0)
273
+
274
+ const memo = index.get('Calc#memo:a')
275
+ expect(memo).toMatchObject({ kind: 'memo', name: 'a' })
276
+ })
277
+
278
+ test('indexes the controlled-signal sync effect under its setter', () => {
279
+ expect(index.get('Calc#effect:controlled:setCount')).toMatchObject({ kind: 'effect' })
280
+ })
281
+
282
+ test('resolves an event stream to source-mapped nodes', () => {
283
+ const events = [
284
+ ev('signalSet', { signal: 'Calc#signal:count' }),
285
+ ev('effectEnter', { subscriber: 'Calc#memo:a' }),
286
+ ]
287
+ const { joined, unattributed } = joinProfilerEvents(events, index)
288
+ expect(joined[0].signal).toMatchObject({ kind: 'signal', name: 'count' })
289
+ expect(joined[1].subscriber).toMatchObject({ kind: 'memo', name: 'a' })
290
+ expect(unattributed).toHaveLength(0)
291
+ })
292
+
293
+ test('surfaces unresolved ids as a coverage gap, never dropping events', () => {
294
+ const events = [
295
+ ev('effectEnter', { subscriber: 'Calc#effect:does-not-exist' }),
296
+ ev('effectExit', { subscriber: 'Calc#effect:does-not-exist', dur: 1 }),
297
+ ]
298
+ const { joined, unattributed } = joinProfilerEvents(events, index)
299
+ expect(joined).toHaveLength(2) // events preserved
300
+ expect(joined[0].subscriber).toBeUndefined()
301
+ expect(unattributed).toEqual([{ id: 'Calc#effect:does-not-exist', count: 2 }])
302
+ })
303
+
304
+ test('routes anonymous runtime ids to diagnostics, not the actionable gap list (#1840)', () => {
305
+ const events = [
306
+ // Compiler id the IR can't place → actionable gap.
307
+ ev('effectEnter', { subscriber: 'Calc#effect:does-not-exist' }),
308
+ // Anonymous runtime bookkeeping ids (no compiler __bfId) → non-actionable.
309
+ ev('signalSet', { signal: 's9' }),
310
+ ev('signalSet', { signal: 's10' }),
311
+ ev('effectEnter', { subscriber: 'r10' }),
312
+ ev('effectEnter', { subscriber: 'e3' }),
313
+ ]
314
+ const { joined, unattributed, diagnostics } = joinProfilerEvents(events, index)
315
+ expect(joined).toHaveLength(5) // nothing dropped
316
+ expect(unattributed.map(u => u.id)).toEqual(['Calc#effect:does-not-exist'])
317
+ expect(diagnostics.map(u => u.id).sort()).toEqual(['e3', 'r10', 's10', 's9'])
318
+ })
319
+ })
320
+
321
+ describe('buildProfileReport (dynamic, SR1–SR4 + analyses)', () => {
322
+ const src = `
323
+ 'use client'
324
+ import { createSignal, createMemo } from '@barefootjs/client'
325
+ export function Calc() {
326
+ const [count, setCount] = createSignal(0)
327
+ const a = createMemo(() => count() * 2)
328
+ return <button onClick={() => setCount(count() + 1)}>{a()}</button>
329
+ }
330
+ `
331
+ let n = 0
332
+ const ev = (type: ProfilerEvent['type'], f: Partial<ProfilerEvent> = {}): ProfilerEvent =>
333
+ ({ type, seq: n++, turn: null, ...f })
334
+
335
+ test('assembles hot subscribers, batch advisor, and coverage from a stream', () => {
336
+ n = 0
337
+ const events: ProfilerEvent[] = [
338
+ ev('effectEnter', { subscriber: 'Calc#memo:a' }), // mount
339
+ ev('effectOutput', { subscriber: 'Calc#memo:a', changed: true }), // mount: real output
340
+ ev('turnBegin', { handlerId: 'Calc#handler:s0:click' }),
341
+ ev('effectEnter', { subscriber: 'Calc#memo:a', turn: 'Calc#handler:s0:click' }),
342
+ ev('effectExit', { subscriber: 'Calc#memo:a', dur: 2, turn: 'Calc#handler:s0:click' }),
343
+ ev('effectOutput', { subscriber: 'Calc#memo:a', changed: false, turn: 'Calc#handler:s0:click' }), // wasted
344
+ ev('turnEnd', {}),
345
+ ]
346
+ const r = buildProfileReport({ source: src, filePath: 'Calc.tsx', scenario: 'auto', events })
347
+ expect(r.kind).toBe('profile')
348
+ expect(r.componentName).toBe('Calc')
349
+ expect(r.turns).toBe(1)
350
+ expect(r.hotSubscribers.subscribers[0].name).toBe('a')
351
+ expect(r.wastedReReruns.subscribers[0].name).toBe('a')
352
+ expect(r.wastedReReruns.subscribers[0].wastedRuns).toBe(1)
353
+ expect(r.coverage.handlersFired).toBe(1)
354
+ expect(r.coverage.handlersTotal).toBeGreaterThanOrEqual(1)
355
+ const out = formatProfileReport(r)
356
+ expect(out).toContain('Calc — profile')
357
+ expect(out).toContain('hot subscribers')
358
+ expect(out).toContain('wasted re-runs')
359
+ expect(out).toContain('coverage:')
360
+ })
361
+
362
+ test('a zero-turn report directs to the right tool', () => {
363
+ n = 0
364
+ // No handler events at all → no turns, no handlers.
365
+ const noHandlerSrc = `
366
+ 'use client'
367
+ import { createSignal } from '@barefootjs/client'
368
+ export function Disp() { const [v] = createSignal(1); return <div>{v()}</div> }
369
+ `
370
+ const events: ProfilerEvent[] = [ev('effectEnter', { subscriber: 'Disp#binding:s0' })]
371
+ const noHandlers = buildProfileReport({ source: noHandlerSrc, filePath: 'Disp.tsx', scenario: 'auto', events })
372
+ expect(noHandlers.turns).toBe(0)
373
+ expect(formatProfileReport(noHandlers)).toContain('no event handlers')
374
+ })
375
+
376
+ test('coverage.diagnostics is a compact summary, not a full id array (#1849 B7)', () => {
377
+ n = 0
378
+ // A long tail of anonymous runtime bookkeeping ids (loop-generated binding
379
+ // ids on a grid component). JSON consumers get a count + a small sample
380
+ // instead of hundreds of `{id,count}` objects.
381
+ const events: ProfilerEvent[] = []
382
+ for (let i = 0; i < 50; i++) {
383
+ events.push(ev('signalSet', { signal: `s${i}` }))
384
+ }
385
+ const r = buildProfileReport({ source: src, filePath: 'Calc.tsx', scenario: 'auto', events })
386
+ expect(r.coverage.diagnostics.count).toBe(50)
387
+ expect(r.coverage.diagnostics.sample.length).toBeLessThanOrEqual(3)
388
+ expect(Array.isArray(r.coverage.diagnostics.sample)).toBe(true)
389
+ // The text report still summarizes by count.
390
+ expect(formatProfileReport(r)).toContain('50 anonymous runtime id(s)')
391
+ })
392
+
393
+ test('omitting topN keeps the full subscriber list (the JSON path, #1849 B1)', () => {
394
+ n = 0
395
+ // Five distinct subscribers — more than a small `--top`. The CLI passes
396
+ // `topN: undefined` in JSON mode so the serialized list is never truncated;
397
+ // pinning the contract here guards that the data layer returns everything
398
+ // when no cap is requested.
399
+ const manySrc = `
400
+ 'use client'
401
+ import { createSignal, createMemo } from '@barefootjs/client'
402
+ export function Many() {
403
+ const [count, setCount] = createSignal(0)
404
+ const a = createMemo(() => count() + 1)
405
+ const b = createMemo(() => count() + 2)
406
+ const c = createMemo(() => count() + 3)
407
+ const d = createMemo(() => count() + 4)
408
+ const e = createMemo(() => count() + 5)
409
+ return <button onClick={() => setCount(count() + 1)}>{a()}{b()}{c()}{d()}{e()}</button>
410
+ }
411
+ `
412
+ const events: ProfilerEvent[] = []
413
+ for (const name of ['a', 'b', 'c', 'd', 'e']) {
414
+ events.push(ev('effectEnter', { subscriber: `Many#memo:${name}` }))
415
+ events.push(ev('effectExit', { subscriber: `Many#memo:${name}`, dur: 1 }))
416
+ }
417
+ const full = buildProfileReport({ source: manySrc, filePath: 'Many.tsx', scenario: 'auto', events })
418
+ expect(full.hotSubscribers.subscribers).toHaveLength(5)
419
+ // With a cap the table truncates — what JSON mode deliberately avoids.
420
+ const capped = buildProfileReport({ source: manySrc, filePath: 'Many.tsx', scenario: 'auto', events, topN: 2 })
421
+ expect(capped.hotSubscribers.subscribers).toHaveLength(2)
422
+ })
423
+ })
424
+
425
+ describe('findUninstrumentedEffects (#1849 B6)', () => {
426
+ let seq = 0
427
+ const ev = (type: ProfilerEvent['type'], f: Partial<ProfilerEvent> = {}): ProfilerEvent =>
428
+ ({ type, seq: seq++, turn: null, ...f })
429
+
430
+ const refEffectSrc = `
431
+ 'use client'
432
+ import { createSignal, createEffect } from '@barefootjs/client'
433
+ export function C() {
434
+ const [open, setOpen] = createSignal(false)
435
+ createEffect(() => console.log(open())) // line 6 — top-level (instrumented)
436
+ const handleMount = (el) => {
437
+ createEffect(() => { el.dataset.state = open() ? 'open' : 'closed' }) // line 8 — nested
438
+ }
439
+ return <button ref={handleMount} onClick={() => setOpen(v => !v)}>{open()}</button>
440
+ }
441
+ `
442
+
443
+ test('returns createEffect sites the compiler did not instrument', () => {
444
+ // The top-level effect is on line 6 (instrumented); the ref-callback effect
445
+ // on line 8 is not. Subtracting the instrumented line yields the nested one.
446
+ const all = findUninstrumentedEffects(refEffectSrc, 'C.tsx', new Set())
447
+ expect(all.map(c => c.line)).toEqual([6, 8])
448
+ const candidates = findUninstrumentedEffects(refEffectSrc, 'C.tsx', new Set([6]))
449
+ expect(candidates).toEqual([{ file: 'C.tsx', line: 8 }])
450
+ })
451
+
452
+ test('buildProfileReport attaches candidates to an uninstrumented e<n> id', () => {
453
+ seq = 0
454
+ // Drive the nested effect under a runtime fallback id `e1` (no __bfId).
455
+ const events: ProfilerEvent[] = [
456
+ ev('effectEnter', { subscriber: 'e1' }),
457
+ ev('effectExit', { subscriber: 'e1', dur: 3 }),
458
+ ]
459
+ const r = buildProfileReport({ source: refEffectSrc, filePath: 'C.tsx', scenario: 'auto', events })
460
+ const e1 = r.hotSubscribers.subscribers.find(s => s.subscriber === 'e1')!
461
+ expect(e1.resolution).toBe('uninstrumented')
462
+ // Only the uninstrumented (line 8) call is a candidate; the instrumented
463
+ // top-level effect (line 6) is excluded.
464
+ expect(e1.candidates).toEqual([{ file: 'C.tsx', line: 8 }])
465
+ })
466
+ })
package/src/compiler.ts CHANGED
@@ -251,6 +251,7 @@ function compileMultipleComponents(
251
251
  options.localImportPrefixes,
252
252
  undefined,
253
253
  multiAdapterCaps,
254
+ options.profile,
254
255
  ) || undefined,
255
256
  adapterTypes: adapterOutput.types || undefined,
256
257
  })
@@ -719,6 +720,7 @@ export function compileJSX(
719
720
  },
720
721
  undefined,
721
722
  adapterCaps,
723
+ options.profile,
722
724
  )
723
725
  errors.push(...componentIR.errors)
724
726
  if (result.code) {
@@ -734,6 +736,7 @@ export function compileJSX(
734
736
  options.localImportPrefixes,
735
737
  undefined,
736
738
  adapterCaps,
739
+ options.profile,
737
740
  )
738
741
  errors.push(...componentIR.errors)
739
742
  if (clientJs) {