@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,198 @@
1
+ /**
2
+ * Batch advisor analysis (#1690, §4.2.3).
3
+ *
4
+ * Per turn, measures effect `totalRuns` vs `distinctSubscribers`; the gap is
5
+ * what a `batch()` wrap would collapse. Measured half — every candidate is
6
+ * `safety: 'unverified'` until the static oracle lands.
7
+ */
8
+
9
+ import { describe, test, expect } from 'bun:test'
10
+ import { analyzeBatchAdvisor, formatBatchAdvisor, buildIdIndex, assessBatchSafety } from '../profiler'
11
+ import { buildComponentAnalysis } from '../debug'
12
+ import type { ProfilerEvent } from '@barefootjs/shared'
13
+
14
+ const SAFETY_SRC = `
15
+ 'use client'
16
+ import { createSignal, createMemo } from '@barefootjs/client'
17
+ export function C() {
18
+ const [a, setA] = createSignal(0)
19
+ const [b, setB] = createSignal(0)
20
+ const sum = createMemo(() => a() + b())
21
+ return <div></div>
22
+ }
23
+ `
24
+ const safetyGraph = buildComponentAnalysis(SAFETY_SRC, 'C.tsx').graph
25
+
26
+ describe('assessBatchSafety (post-write-derived-read oracle, §4.2.3)', () => {
27
+ const base = { hasIndirectSetters: false, graph: safetyGraph }
28
+
29
+ test('writes only, no derived read → safe', () => {
30
+ expect(assessBatchSafety({ ...base, handler: '() => { setA(1); setB(2) }', setterNames: ['setA', 'setB'], writtenSignals: ['a', 'b'] })).toBe('safe')
31
+ })
32
+ test('reads a downstream memo after a write → unsafe', () => {
33
+ expect(assessBatchSafety({ ...base, handler: '() => { setA(1); console.log(sum()) }', setterNames: ['setA'], writtenSignals: ['a'] })).toBe('unsafe')
34
+ })
35
+ test('reads the memo before the write → safe', () => {
36
+ expect(assessBatchSafety({ ...base, handler: '() => { const x = sum(); setA(x) }', setterNames: ['setA'], writtenSignals: ['a'] })).toBe('safe')
37
+ })
38
+ test('indirect setters (via a helper) → unverified', () => {
39
+ expect(assessBatchSafety({ ...base, handler: '() => doStuff()', hasIndirectSetters: true, setterNames: ['setA'], writtenSignals: ['a'] })).toBe('unverified')
40
+ })
41
+ test('an unknown call after a write → unverified (could read a memo)', () => {
42
+ expect(assessBatchSafety({ ...base, handler: '() => { setA(1); maybeReads() }', setterNames: ['setA'], writtenSignals: ['a'] })).toBe('unverified')
43
+ })
44
+ })
45
+
46
+ let seq = 0
47
+ // `turn`/`turnSeq` are both always present on a real SR2 event (`number | null`).
48
+ // Default them here so fixtures match the wire contract; per-turn helpers below
49
+ // stamp a concrete `turn`, and grouping falls back to `turn` when `turnSeq` is
50
+ // null, so distinct turn ids stay distinct.
51
+ const ev = (type: ProfilerEvent['type'], f: Partial<ProfilerEvent> = {}): ProfilerEvent =>
52
+ ({ type, seq: seq++, turn: null, turnSeq: null, ...f })
53
+
54
+ // A signal write the handler makes directly (depth 0). The recording sink only
55
+ // stamps the turn while one is open, so `turn` is always set for these.
56
+ const set = (signal: string, turn: string): ProfilerEvent => ev('signalSet', { signal, turn })
57
+
58
+ // One effect run, modeled the way the real sink records it: an `effectEnter`
59
+ // bracketed by its `effectExit`. Pairing them is what returns the effect-stack
60
+ // depth to 0 between runs, so a later `set()` in the same turn is still seen as
61
+ // a direct (depth-0) handler write. `nested` events (e.g. a memo writing its
62
+ // private signal) are emitted *between* the enter/exit, i.e. at depth > 0.
63
+ const run = (
64
+ subscriber: string,
65
+ turn: string,
66
+ nested: ProfilerEvent[] = [],
67
+ ): ProfilerEvent[] => [
68
+ ev('effectEnter', { subscriber, turn }),
69
+ ...nested,
70
+ ev('effectExit', { subscriber, turn, dur: 0 }),
71
+ ]
72
+
73
+ describe('analyzeBatchAdvisor', () => {
74
+ test('savings = totalRuns - distinctSubscribers for a multi-write turn', () => {
75
+ seq = 0
76
+ // Turn t1: write a, its cascade re-runs e1+e2, then (depth back to 0) write
77
+ // b re-runs e1+e2 again → 4 runs, 2 distinct, 2 handler writes.
78
+ const events: ProfilerEvent[] = [
79
+ set('C#signal:a', 't1'),
80
+ ...run('C#effect:e1', 't1'),
81
+ ...run('C#effect:e2', 't1'),
82
+ set('C#signal:b', 't1'),
83
+ ...run('C#effect:e1', 't1'),
84
+ ...run('C#effect:e2', 't1'),
85
+ ]
86
+ const r = analyzeBatchAdvisor(events)
87
+ expect(r.candidates).toHaveLength(1)
88
+ expect(r.candidates[0]).toMatchObject({
89
+ turn: 't1', totalRuns: 4, distinctSubscribers: 2, writes: 2, savings: 2, safety: 'unverified',
90
+ })
91
+ })
92
+
93
+ test('a single-write turn is never a candidate, however wide it fans out', () => {
94
+ seq = 0
95
+ // One `set()` recomputes a memo (which writes its own private signal — a
96
+ // nested, depth-1 set that must NOT count as a handler write) and then
97
+ // repaints a 4-cell loop: one binding id, four runs. `batch()` would
98
+ // collapse nothing (one handler write), so this is not a candidate — the
99
+ // pre-#1690 model wrongly read it as "saves 4".
100
+ const events: ProfilerEvent[] = [
101
+ set('Grid#signal:selected', 't1'),
102
+ ...run('Grid#memo:rows', 't1', [set('Grid#memo:rows', 't1')]),
103
+ ...run('Grid#binding:s9', 't1'),
104
+ ...run('Grid#binding:s9', 't1'),
105
+ ...run('Grid#binding:s9', 't1'),
106
+ ...run('Grid#binding:s9', 't1'),
107
+ ]
108
+ expect(analyzeBatchAdvisor(events).candidates).toHaveLength(0)
109
+ })
110
+
111
+ test('a turn where every effect runs once is not a candidate', () => {
112
+ seq = 0
113
+ const events: ProfilerEvent[] = [
114
+ set('C#signal:a', 't1'),
115
+ ...run('C#effect:e1', 't1'),
116
+ set('C#signal:b', 't1'),
117
+ ...run('C#effect:e2', 't1'),
118
+ ]
119
+ expect(analyzeBatchAdvisor(events).candidates).toHaveLength(0)
120
+ })
121
+
122
+ test('runs outside any turn are ignored', () => {
123
+ seq = 0
124
+ const events: ProfilerEvent[] = [
125
+ ev('signalSet', { signal: 'C#signal:a' }), // turn null
126
+ ev('signalSet', { signal: 'C#signal:b' }),
127
+ ev('effectEnter', { subscriber: 'C#effect:e1' }), // turn null
128
+ ev('effectExit', { subscriber: 'C#effect:e1' }),
129
+ ev('effectEnter', { subscriber: 'C#effect:e1' }),
130
+ ev('effectExit', { subscriber: 'C#effect:e1' }),
131
+ ]
132
+ expect(analyzeBatchAdvisor(events).candidates).toHaveLength(0)
133
+ })
134
+
135
+ test('ranks turns by savings descending', () => {
136
+ seq = 0
137
+ const events: ProfilerEvent[] = [
138
+ // t1 saves 1 (two writes, e1 runs once per write)
139
+ set('C#signal:a', 't1'),
140
+ ...run('C#effect:e1', 't1'),
141
+ set('C#signal:b', 't1'),
142
+ ...run('C#effect:e1', 't1'),
143
+ // t2 saves 3 (two writes, e1 runs four times)
144
+ set('C#signal:a', 't2'),
145
+ ...run('C#effect:e1', 't2'),
146
+ ...run('C#effect:e1', 't2'),
147
+ set('C#signal:b', 't2'),
148
+ ...run('C#effect:e1', 't2'),
149
+ ...run('C#effect:e1', 't2'),
150
+ ]
151
+ const r = analyzeBatchAdvisor(events)
152
+ expect(r.candidates.map(c => c.turn)).toEqual(['t2', 't1'])
153
+ expect(r.candidates[0].savings).toBe(3)
154
+ })
155
+
156
+ test('resolves the handler turn id to source loc when given an id index', () => {
157
+ seq = 0
158
+ const src = `
159
+ 'use client'
160
+ import { createSignal } from '@barefootjs/client'
161
+ export function Form() {
162
+ const [a, setA] = createSignal(0)
163
+ const [b, setB] = createSignal(0)
164
+ return <button onClick={() => { setA(1); setB(2) }}>{a() + b()}</button>
165
+ }
166
+ `
167
+ const { graph } = buildComponentAnalysis(src, 'Form.tsx')
168
+ const index = buildIdIndex(graph)
169
+ // Find the handler turn id the compiler/runtime would emit for the button.
170
+ const turn = [...index.keys()].find(k => k.startsWith('Form#handler:'))!
171
+ expect(turn).toBeDefined()
172
+ const events: ProfilerEvent[] = [
173
+ set('Form#signal:a', turn),
174
+ ...run('Form#binding:s0', turn),
175
+ set('Form#signal:b', turn),
176
+ ...run('Form#binding:s0', turn),
177
+ ]
178
+ const c = analyzeBatchAdvisor(events, index).candidates[0]
179
+ expect(c.loc?.file).toBe('Form.tsx')
180
+ expect(c.loc?.line).toBeGreaterThan(0)
181
+ expect(formatBatchAdvisor(analyzeBatchAdvisor(events, index))).toMatch(/\(Form\.tsx:\d+\)/)
182
+ })
183
+
184
+ test('formats candidates and never claims safety in the measured half', () => {
185
+ seq = 0
186
+ const turn = 'Checkout#handler:s0:submit'
187
+ const events: ProfilerEvent[] = [
188
+ set('Checkout#signal:a', turn),
189
+ ...run('C#effect:e1', turn),
190
+ set('Checkout#signal:b', turn),
191
+ ...run('C#effect:e1', turn),
192
+ ]
193
+ const out = formatBatchAdvisor(analyzeBatchAdvisor(events))
194
+ expect(out).toContain('batch candidate 2→1')
195
+ expect(out).toContain('safety unverified')
196
+ expect(out).not.toContain(', safe)')
197
+ })
198
+ })
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Profiler coverage conformance (#1690, SR4).
3
+ *
4
+ * This is the drift guard for the profiler's id instrumentation. The compiler
5
+ * attributes reactive re-runs to source by emitting `<Component>#binding:<slot>`
6
+ * / `#handler:<slot>:<event>` / `#signal:<name>` / `#memo:<name>` /
7
+ * `#effect:<line>` ids, and `buildIdIndex` resolves them back to a source line.
8
+ * Because a binding/loop id is an *optional* trailing argument (required for
9
+ * SR8 — byte-identical output when profiling is off), forgetting to thread it
10
+ * into a NEW emit path is not a type error; the effect would silently re-run as
11
+ * a bare runtime id and never show attributed in `bf debug profile`.
12
+ *
13
+ * `computeGaps()` computes the bidirectional invariant for one component:
14
+ *
15
+ * (a) coverage — every reactive entity the analyzer reports (DOM bindings,
16
+ * event handlers, signals, memos) is emitted with a matching id. A new
17
+ * emit path that forgets its id surfaces here as `missing`.
18
+ * (b) resolution — every `<Comp>#…` id emitted in the client JS resolves via
19
+ * `buildIdIndex`. A dropped/renamed analyzer entity, or an id built from a
20
+ * non-slot (`#binding:?`), surfaces here as `unresolved`.
21
+ *
22
+ * The MATRIX exercises every reactive emit path. If you add a new emit shape,
23
+ * add a representative component to it and thread `profileComponentName` until
24
+ * `computeGaps()` is empty — that is the contract. The `guard self-test` block
25
+ * proves `computeGaps()` actually detects a dropped id and a bogus id, so the
26
+ * detector itself cannot rot silently.
27
+ */
28
+
29
+ import { describe, test, expect } from 'bun:test'
30
+ import { compileJSX } from '../compiler'
31
+ import { TestAdapter } from '../adapters/test-adapter'
32
+ import { buildIdIndex } from '../profiler'
33
+ import { buildComponentAnalysis } from '../debug'
34
+
35
+ const adapter = new TestAdapter()
36
+
37
+ // Compilation/analysis are pure for a given (source, name) — memoize so the
38
+ // 13-component matrix (each touched by several assertions) compiles each shape
39
+ // at most twice (on/off) instead of dozens of times. Keeps the suite snappy.
40
+ const jsCache = new Map<string, string>()
41
+ function clientJs(source: string, name: string, profile: boolean): string {
42
+ const key = `${name}:${profile}`
43
+ let js = jsCache.get(key)
44
+ if (js === undefined) {
45
+ js = compileJSX(source, `${name}.tsx`, { adapter, profile })
46
+ .files.find(f => f.type === 'clientJs')!.content
47
+ jsCache.set(key, js)
48
+ }
49
+ return js
50
+ }
51
+
52
+ const graphCache = new Map<string, ReturnType<typeof buildComponentAnalysis>['graph']>()
53
+ function analyze(source: string, name: string): ReturnType<typeof buildComponentAnalysis>['graph'] {
54
+ let g = graphCache.get(name)
55
+ if (g === undefined) {
56
+ g = buildComponentAnalysis(source, `${name}.tsx`).graph
57
+ graphCache.set(name, g)
58
+ }
59
+ return g
60
+ }
61
+
62
+ /** All `<Comp>#…` profiler ids present in a profile-mode build. */
63
+ function emittedIds(name: string, on: string): string[] {
64
+ const re = new RegExp(`"(${name}#(?:signal|memo|effect|binding|handler):[^"]+)"`, 'g')
65
+ return [...new Set([...on.matchAll(re)].map(m => m[1]))]
66
+ }
67
+
68
+ interface Gaps {
69
+ /** Analyzer entities with no emitted id (a forgotten emit-side thread). */
70
+ missing: string[]
71
+ /** Emitted ids that `buildIdIndex` cannot resolve (a dangling id). */
72
+ unresolved: string[]
73
+ }
74
+
75
+ /**
76
+ * The bidirectional coverage check, as a pure function over a component's
77
+ * profile-mode client JS and its analysis graph. Shared by the matrix tests and
78
+ * the guard self-test (which feeds it deliberately-broken input).
79
+ */
80
+ function computeGaps(name: string, on: string, graph: ReturnType<typeof buildComponentAnalysis>['graph']): Gaps {
81
+ const index = buildIdIndex(graph)
82
+ const missing: string[] = []
83
+ for (const b of graph.domBindings) {
84
+ if (b.slotId === '?') continue // slot-less → not emittable, analyzer emits none either
85
+ if (b.type === 'event') {
86
+ if (!on.includes(`${name}#handler:${b.slotId}:`)) missing.push(`handler ${b.slotId}`)
87
+ } else if (!on.includes(`"${name}#binding:${b.slotId}"`)) {
88
+ missing.push(`${b.type} ${b.slotId}`)
89
+ }
90
+ }
91
+ for (const s of graph.signals) {
92
+ if (!on.includes(`"${name}#signal:${s.name}"`)) missing.push(`signal ${s.name}`)
93
+ }
94
+ for (const m of graph.memos) {
95
+ if (!on.includes(`"${name}#memo:${m.name}"`)) missing.push(`memo ${m.name}`)
96
+ }
97
+ const unresolved = emittedIds(name, on).filter(id => !index.has(id))
98
+ return { missing, unresolved }
99
+ }
100
+
101
+ function gapsFor(name: string, source: string): Gaps {
102
+ return computeGaps(name, clientJs(source, name, true), analyze(source, name))
103
+ }
104
+
105
+ /** One representative component per distinct reactive emit path. */
106
+ const MATRIX: ReadonlyArray<{ name: string; desc: string; source: string }> = [
107
+ {
108
+ name: 'TopLevel',
109
+ desc: 'top-level text + attribute + handler',
110
+ source: `
111
+ 'use client'
112
+ import { createSignal } from '@barefootjs/client'
113
+ export function TopLevel() {
114
+ const [n, setN] = createSignal(0)
115
+ return <button onClick={() => setN(n() + 1)} class={n() > 0 ? 'on' : 'off'}>{n()}</button>
116
+ }`,
117
+ },
118
+ {
119
+ name: 'PropAttr',
120
+ desc: 'prop-driven attribute bindings (with and without a co-located handler)',
121
+ source: `
122
+ 'use client'
123
+ import { createSignal } from '@barefootjs/client'
124
+ export function PropAttr(props: { id?: string; disabled?: boolean; className?: string }) {
125
+ const [n, setN] = createSignal(0)
126
+ return (
127
+ <div id={props.id} class={\`base \${props.className ?? ''}\`}>
128
+ <button onClick={() => setN(n() + 1)} class={\`btn \${props.className ?? ''}\`}>{n()}</button>
129
+ </div>
130
+ )
131
+ }`,
132
+ },
133
+ {
134
+ name: 'SignalMemoEffect',
135
+ desc: 'signal + memo + user effect + keyboard handler',
136
+ source: `
137
+ 'use client'
138
+ import { createSignal, createMemo, createEffect } from '@barefootjs/client'
139
+ export function SignalMemoEffect() {
140
+ const [n, setN] = createSignal(0)
141
+ const dbl = createMemo(() => n() * 2)
142
+ createEffect(() => { console.log(dbl()) })
143
+ return <button onClick={() => setN(n() + 1)} onKeyDown={() => setN(0)}>{dbl()}</button>
144
+ }`,
145
+ },
146
+ {
147
+ name: 'CondBranch',
148
+ desc: 'conditional + branch attribute/text',
149
+ source: `
150
+ 'use client'
151
+ import { createSignal } from '@barefootjs/client'
152
+ export function CondBranch() {
153
+ const [o, setO] = createSignal(false)
154
+ return (
155
+ <div>
156
+ <button onClick={() => setO(!o())}>t</button>
157
+ {o() && <p class={o() ? 'a' : 'b'}>{o() ? 'yes' : 'no'}</p>}
158
+ </div>
159
+ )
160
+ }`,
161
+ },
162
+ {
163
+ name: 'NestedConditional',
164
+ desc: 'a conditional nested inside another conditional branch',
165
+ source: `
166
+ 'use client'
167
+ import { createSignal } from '@barefootjs/client'
168
+ export function NestedConditional() {
169
+ const [a, setA] = createSignal(true)
170
+ const [b] = createSignal(true)
171
+ return (
172
+ <div>
173
+ <button onClick={() => setA(!a())}>x</button>
174
+ {a() && <div>{b() ? <span>{a() ? 'y' : 'n'}</span> : <em>e</em>}</div>}
175
+ </div>
176
+ )
177
+ }`,
178
+ },
179
+ {
180
+ name: 'LoopChild',
181
+ desc: 'loop child text + attribute',
182
+ source: `
183
+ 'use client'
184
+ import { createSignal } from '@barefootjs/client'
185
+ export function LoopChild() {
186
+ const [items] = createSignal([{ id: 1, t: 'a', n: 0 }])
187
+ return <ul>{items().map(it => <li key={it.id} class={it.n > 0 ? 'h' : 'c'}>{it.t}</li>)}</ul>
188
+ }`,
189
+ },
190
+ {
191
+ name: 'LoopMultiAttr',
192
+ desc: 'loop child with two reactive attributes on one element',
193
+ source: `
194
+ 'use client'
195
+ import { createSignal } from '@barefootjs/client'
196
+ export function LoopMultiAttr() {
197
+ const [items] = createSignal([{ id: 1, n: 0, t: 'a' }])
198
+ return <ul>{items().map(it => <li key={it.id} class={it.n > 0 ? 'h' : 'c'} data-t={it.t}>{it.t}</li>)}</ul>
199
+ }`,
200
+ },
201
+ {
202
+ name: 'Nested',
203
+ desc: 'loop → conditional+branch-text → inner loop → text',
204
+ source: `
205
+ 'use client'
206
+ import { createSignal } from '@barefootjs/client'
207
+ export function Nested() {
208
+ const [rows] = createSignal([{ id: 1, on: true, label: 'a', tags: ['x'] }])
209
+ return (
210
+ <ul>
211
+ {rows().map(r => (
212
+ <li key={r.id}>
213
+ {r.on ? <span>{r.label}</span> : <em>off</em>}
214
+ <ul>{r.tags.map(t => <li key={t}>{t}</li>)}</ul>
215
+ </li>
216
+ ))}
217
+ </ul>
218
+ )
219
+ }`,
220
+ },
221
+ {
222
+ name: 'InnerLoopAttr',
223
+ desc: 'inner (nested) loop child with a reactive attribute',
224
+ source: `
225
+ 'use client'
226
+ import { createSignal } from '@barefootjs/client'
227
+ export function InnerLoopAttr() {
228
+ const [rows] = createSignal([{ id: 1, tags: [{ k: 'x', on: true }] }])
229
+ return (
230
+ <ul>
231
+ {rows().map(r => (
232
+ <li key={r.id}>
233
+ <ul>{r.tags.map(t => <li key={t.k} class={t.on ? 'a' : 'b'}>{t.k}</li>)}</ul>
234
+ </li>
235
+ ))}
236
+ </ul>
237
+ )
238
+ }`,
239
+ },
240
+ {
241
+ name: 'BranchLoop',
242
+ desc: 'loop inside a conditional branch',
243
+ source: `
244
+ 'use client'
245
+ import { createSignal } from '@barefootjs/client'
246
+ export function BranchLoop() {
247
+ const [open] = createSignal(true)
248
+ const [items] = createSignal([{ id: 1, t: 'a' }])
249
+ return <div>{open() && <ul>{items().map(it => <li key={it.id}>{it.t}</li>)}</ul>}</div>
250
+ }`,
251
+ },
252
+ {
253
+ name: 'AnchoredLoop',
254
+ desc: 'whole-item conditional loop (mapArrayAnchored)',
255
+ source: `
256
+ 'use client'
257
+ import { createSignal } from '@barefootjs/client'
258
+ export function AnchoredLoop() {
259
+ const [items] = createSignal([{ id: 1, on: true, t: 'a' }])
260
+ return <ul>{items().map(it => (it.on ? <li key={it.id}>{it.t}</li> : null))}</ul>
261
+ }`,
262
+ },
263
+ {
264
+ name: 'StaticLoop',
265
+ desc: 'static-array loop with signal-driven child text',
266
+ source: `
267
+ 'use client'
268
+ import { createSignal } from '@barefootjs/client'
269
+ export function StaticLoop() {
270
+ const [n] = createSignal(0)
271
+ const tabs = [{ id: 1 }, { id: 2 }]
272
+ return <ul>{tabs.map(tab => <li key={tab.id}>{n()}</li>)}</ul>
273
+ }`,
274
+ },
275
+ {
276
+ name: 'ComponentLoop',
277
+ desc: 'loop whose body is a child component',
278
+ source: `
279
+ 'use client'
280
+ import { createSignal } from '@barefootjs/client'
281
+ import { Row } from './Row'
282
+ export function ComponentLoop() {
283
+ const [items] = createSignal([{ id: 1, label: 'a' }])
284
+ return <div>{items().map(it => <Row key={it.id} label={it.label} />)}</div>
285
+ }`,
286
+ },
287
+ {
288
+ name: 'CompositeLoop',
289
+ desc: 'loop body mixing a DOM binding and a child component',
290
+ source: `
291
+ 'use client'
292
+ import { createSignal } from '@barefootjs/client'
293
+ import { Row } from './Row'
294
+ export function CompositeLoop() {
295
+ const [items] = createSignal([{ id: 1, t: 'a' }])
296
+ return <ul>{items().map(it => <li key={it.id}><span>{it.t}</span><Row label={it.t} /></li>)}</ul>
297
+ }`,
298
+ },
299
+ ]
300
+
301
+ describe('profiler coverage conformance (#1690 SR4)', () => {
302
+ for (const { name, desc, source } of MATRIX) {
303
+ describe(`${name} — ${desc}`, () => {
304
+ test('(a)+(b) every reactive entity is emitted and every emitted id resolves', () => {
305
+ const { missing, unresolved } = gapsFor(name, source)
306
+ expect(missing, `un-instrumented entities in ${name}`).toEqual([])
307
+ expect(unresolved, `unresolved emitted ids in ${name}`).toEqual([])
308
+ })
309
+
310
+ test('profile mode actually emitted ids (sanity)', () => {
311
+ const on = clientJs(source, name, true)
312
+ expect(emittedIds(name, on).length, `${name} emitted no profiler ids`).toBeGreaterThan(0)
313
+ })
314
+
315
+ test('profile off: no profiler ids (SR8)', () => {
316
+ const off = clientJs(source, name, false)
317
+ expect(off).not.toContain('#binding:')
318
+ expect(off).not.toContain('#handler:')
319
+ expect(off).not.toContain('#signal:')
320
+ expect(off).not.toContain('#memo:')
321
+ })
322
+ })
323
+ }
324
+
325
+ // The detector must itself be proven to fail on broken input — otherwise a
326
+ // bug in `computeGaps` would make the whole matrix a no-op that passes on
327
+ // anything. We feed it a real build with one tampering and assert the gap is
328
+ // reported, for each of the two failure modes.
329
+ describe('guard self-test — computeGaps detects tampering', () => {
330
+ const name = 'Nested'
331
+ const source = MATRIX.find(m => m.name === name)!.source
332
+
333
+ test('clean build reports no gaps', () => {
334
+ const { missing, unresolved } = gapsFor(name, source)
335
+ expect(missing).toEqual([])
336
+ expect(unresolved).toEqual([])
337
+ })
338
+
339
+ test('a DROPPED binding id is reported as missing (a)', () => {
340
+ const on = clientJs(source, name, true)
341
+ const id = emittedIds(name, on).find(i => i.includes('#binding:'))!
342
+ // Strip every emission of that one id (the trailing `, "<id>"` argument).
343
+ const tampered = on.replaceAll(`, ${JSON.stringify(id)}`, '')
344
+ const { missing, unresolved } = computeGaps(name, tampered, analyze(source, name))
345
+ const slot = id.split('#binding:')[1]
346
+ expect(missing.some(m => m.endsWith(slot))).toBe(true)
347
+ // Dropping an id doesn't create dangling ids.
348
+ expect(unresolved).toEqual([])
349
+ })
350
+
351
+ test('a BOGUS (unresolvable) id is reported as unresolved (b)', () => {
352
+ const on = clientJs(source, name, true)
353
+ // Inject an id whose slot has no domBinding (mirrors a future emit path
354
+ // that builds an id from a non-slot, e.g. the `#binding:?` regression).
355
+ const tampered = `${on}\nconst __probe = "Nested#binding:s999"\n`
356
+ const { unresolved } = computeGaps(name, tampered, analyze(source, name))
357
+ expect(unresolved).toContain('Nested#binding:s999')
358
+ })
359
+ })
360
+ })
@@ -0,0 +1,104 @@
1
+ /**
2
+ * End-to-end profiling on the real substrate (#1690).
3
+ *
4
+ * Unlike the per-analysis unit tests (which feed synthetic event streams),
5
+ * this drives the **actual** instrumented runtime — `reactive.ts` (SR1),
6
+ * `createRecordingSink` (SR2) — over a reactive graph that mirrors the
7
+ * compiler's profile-mode output 1:1, using the exact ids it emits
8
+ * (`Cart#signal:qty`, `Cart#memo:total`, `Cart#effect:<line>`,
9
+ * `Cart#handler:s1:click`). It then joins to the real IR graph (SR4) and runs
10
+ * the v1 analyses, asserting the end-to-end story holds:
11
+ *
12
+ * an unbatched 3-write click re-runs `total` 5×/turn; a `batch()` would
13
+ * collapse 14 effect runs to 5.
14
+ *
15
+ * This is the executable proof of the pipeline until the `--scenario` run
16
+ * driver lands; it also pins the mount-vs-interaction metric split surfaced by
17
+ * running it.
18
+ */
19
+
20
+ import { describe, test, expect, afterEach } from 'bun:test'
21
+ import {
22
+ createSignal, createMemo, createEffect, createRoot,
23
+ beginTurn, endTurn, setProfilerSink,
24
+ } from '../../../client/src/reactive'
25
+ import { createRecordingSink } from '../../../client/src/profiler-events'
26
+ import { buildIdIndex, analyzeHotSubscribers, analyzeBatchAdvisor } from '../profiler'
27
+ import { buildComponentAnalysis } from '../debug'
28
+
29
+ const CART = `
30
+ 'use client'
31
+ import { createSignal, createMemo, createEffect } from '@barefootjs/client'
32
+ export function Cart() {
33
+ const [qty, setQty] = createSignal(1)
34
+ const [price, setPrice] = createSignal(100)
35
+ const [coupon, setCoupon] = createSignal(0)
36
+ const subtotal = createMemo(() => qty() * price())
37
+ const tax = createMemo(() => subtotal() * 0.1)
38
+ const total = createMemo(() => subtotal() + tax() - coupon())
39
+ createEffect(() => { console.log('total', total()) })
40
+ createEffect(() => { console.log('subtotal', subtotal()) })
41
+ return <button onClick={() => { setQty(qty() + 1); setPrice(price() + 10); setCoupon(5) }}>{total()}</button>
42
+ }
43
+ `
44
+
45
+ afterEach(() => setProfilerSink(null))
46
+
47
+ /** Run the mirrored Cart graph under the recording sink and return the log. */
48
+ function profileCartClick() {
49
+ const rec = createRecordingSink()
50
+ setProfilerSink(rec.sink)
51
+ createRoot(() => {
52
+ const [qty, setQty] = createSignal(1, 'Cart#signal:qty')
53
+ const [price, setPrice] = createSignal(100, 'Cart#signal:price')
54
+ const [coupon, setCoupon] = createSignal(0, 'Cart#signal:coupon')
55
+ const subtotal = createMemo(() => qty() * price(), 'Cart#memo:subtotal')
56
+ const tax = createMemo(() => subtotal() * 0.1, 'Cart#memo:tax')
57
+ const total = createMemo(() => subtotal() + tax() - coupon(), 'Cart#memo:total')
58
+ createEffect(() => { void total() }, 'Cart#effect:11')
59
+ createEffect(() => { void subtotal() }, 'Cart#effect:12')
60
+ // The compiled handler wrapper: beginTurn → handler body → endTurn.
61
+ beginTurn('Cart#handler:s1:click')
62
+ try { setQty(qty() + 1); setPrice(price() + 10); setCoupon(5) } finally { endTurn() }
63
+ })
64
+ setProfilerSink(null)
65
+ return rec.events
66
+ }
67
+
68
+ describe('profiler end-to-end (real substrate)', () => {
69
+ test('hot subscribers ranks `total` worst and source-maps it, mount excluded', () => {
70
+ const { graph } = buildComponentAnalysis(CART, 'Cart.tsx')
71
+ const index = buildIdIndex(graph)
72
+ const events = profileCartClick()
73
+
74
+ const hot = analyzeHotSubscribers(events, index)
75
+ const total = hot.subscribers.find(s => s.name === 'total')!
76
+ expect(total).toBeDefined()
77
+ expect(total.kind).toBe('memo')
78
+ expect(total.loc!.file).toBe('Cart.tsx')
79
+ // The unbatched 3-write turn re-runs `total` 5× — and mount (1 run) is
80
+ // excluded, so the per-turn figure is 5.0, not the diluted 3.0.
81
+ expect(total.mountRuns).toBe(1)
82
+ expect(total.turns).toBe(1)
83
+ expect(total.runsPerTurn).toBe(5)
84
+ expect(total.hot).toBe(true)
85
+
86
+ // Every hot subscriber resolved to a real source line.
87
+ for (const s of hot.subscribers) {
88
+ if (s.subscriber.startsWith('Cart#')) expect(s.loc).toBeDefined()
89
+ }
90
+ })
91
+
92
+ test('batch advisor reports the multi-write turn as a real saving', () => {
93
+ const events = profileCartClick()
94
+ const batch = analyzeBatchAdvisor(events)
95
+ const cand = batch.candidates.find(c => c.turn === 'Cart#handler:s1:click')!
96
+ expect(cand).toBeDefined()
97
+ // 3 unbatched writes cascade the memo chain: 14 effect runs, 5 distinct.
98
+ expect(cand.writes).toBe(3)
99
+ expect(cand.totalRuns).toBe(14)
100
+ expect(cand.distinctSubscribers).toBe(5)
101
+ expect(cand.savings).toBe(9)
102
+ expect(cand.safety).toBe('unverified')
103
+ })
104
+ })