@barefootjs/client 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.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Profiler event collection (#1690, SR2).
3
+ *
4
+ * The reactive runtime (`reactive.ts`) reports raw instrumentation through a
5
+ * `ProfilerEventSink` (SR1). This module turns that callback stream into a
6
+ * flat, ordered, **turn-stamped** event log — the normalized SR2 contract the
7
+ * analyses (hot subscribers / wasted re-runs / batch advisor) and the SR4 IR
8
+ * join consume.
9
+ *
10
+ * The collector is the one place that knows the *current turn*: it tracks the
11
+ * `beginTurn`/`endTurn` stack (SR3) and stamps every event with the handler id
12
+ * in scope when it fired, so per-turn metrics (batch savings, runs/turn) need
13
+ * no microtask guesswork.
14
+ *
15
+ * Dev-only (SR8): nothing here runs unless `setProfilerSink` is handed a
16
+ * recording sink, which only the instrumented/profile build does.
17
+ */
18
+
19
+ import type { ProfilerEventSink } from "./reactive.ts"
20
+ import type { ProfilerEvent } from '@barefootjs/shared'
21
+
22
+ // The event wire contract lives in `@barefootjs/shared` (built first, depended
23
+ // on by both this runtime and the jsx analyses) — see that module for why.
24
+ export type { ProfilerEvent, ProfilerEventType } from '@barefootjs/shared'
25
+
26
+ export interface RecordingSink {
27
+ /** The sink to hand to `setProfilerSink(...)`. */
28
+ sink: ProfilerEventSink
29
+ /** The collected event log, in emission order. */
30
+ events: ProfilerEvent[]
31
+ /** Clear the log and the internal turn stack (start a fresh scenario). */
32
+ reset(): void
33
+ }
34
+
35
+ /**
36
+ * Build a recording sink (SR2). Hand `.sink` to `setProfilerSink`, drive a
37
+ * scenario, then read `.events` — a turn-stamped, ordered log ready for the
38
+ * SR4 join and the analyses. Turns may nest (a handler that dispatches another
39
+ * handler); the stack's top is the attributed turn.
40
+ */
41
+ export function createRecordingSink(): RecordingSink {
42
+ const events: ProfilerEvent[] = []
43
+ // Each entry is one turn *invocation*: its handler id + a unique instance seq,
44
+ // so repeated calls of the same handler are distinct turns.
45
+ const turnStack: { handlerId: string; turnSeq: number }[] = []
46
+ let seq = 0
47
+ let turnCounter = 0
48
+
49
+ const top = () => (turnStack.length > 0 ? turnStack[turnStack.length - 1] : null)
50
+
51
+ const push = (e: Omit<ProfilerEvent, 'seq' | 'turn' | 'turnSeq'>): void => {
52
+ const t = top()
53
+ events.push({ seq: seq++, turn: t?.handlerId ?? null, turnSeq: t?.turnSeq ?? null, ...e })
54
+ }
55
+
56
+ const sink: ProfilerEventSink = {
57
+ signalSet: (id, batched) => push({ type: 'signalSet', signal: id, batched }),
58
+ subscribeAdd: (signal, subscriber) => push({ type: 'subscribeAdd', signal, subscriber }),
59
+ subscribeRemove: (signal, subscriber) => push({ type: 'subscribeRemove', signal, subscriber }),
60
+ effectCreate: (id, kind) => push({ type: 'effectCreate', subscriber: id, kind }),
61
+ effectEnter: (id) => push({ type: 'effectEnter', subscriber: id }),
62
+ effectExit: (id, dur) => push({ type: 'effectExit', subscriber: id, dur }),
63
+ effectOutput: (id, changed) => push({ type: 'effectOutput', subscriber: id, changed }),
64
+ effectDispose: (id) => push({ type: 'effectDispose', subscriber: id }),
65
+ batchBegin: (depth) => push({ type: 'batchBegin', depth }),
66
+ batchFlush: (flushed) => push({ type: 'batchFlush', flushed }),
67
+ turnBegin: (handlerId, loc) => {
68
+ // Record before pushing so the marker carries the *parent* turn, then
69
+ // open the new turn (a fresh invocation instance) for everything that follows.
70
+ push({ type: 'turnBegin', handlerId, loc })
71
+ turnStack.push({ handlerId, turnSeq: ++turnCounter })
72
+ },
73
+ turnEnd: () => {
74
+ turnStack.pop()
75
+ push({ type: 'turnEnd' })
76
+ },
77
+ }
78
+
79
+ return {
80
+ sink,
81
+ events,
82
+ reset() {
83
+ events.length = 0
84
+ turnStack.length = 0
85
+ seq = 0
86
+ turnCounter = 0
87
+ },
88
+ }
89
+ }
package/src/reactive.ts CHANGED
@@ -23,14 +23,136 @@ export type CleanupFn = () => void
23
23
  export type EffectFn = () => void | CleanupFn
24
24
  export type Memo<T> = Reactive<() => T>
25
25
 
26
+ // -- Dev-only instrumentation (SR1 / SR8, #1690) ------------------------------
27
+ //
28
+ // A single, gated sink the reactive choke points call when profiling is on.
29
+ // When `profilerSink` is null (the production default) every choke point is a
30
+ // single null-check branch with no allocation, and the sink + id params
31
+ // dead-code-eliminate from prod builds since they are never set (#1690).
32
+ // The sink, its state, and the identity bookkeeping all live in this module
33
+ // because `reactive.ts` is published as one physical entry
34
+ // (`@barefootjs/client/reactive`); splitting the sink into a sibling file
35
+ // would bundle a second copy into this entry and break the live binding.
36
+
37
+ export type SubscriberKind = 'effect' | 'memo' | 'root'
38
+
39
+ /**
40
+ * Reactive measurement hooks. Every method is a measurement-only notification —
41
+ * implementations MUST NOT mutate reactive state or throw (a throw would change
42
+ * `set()`'s synchronous semantics). Ids are stable handles; `''` means the
43
+ * node was created while profiling was off. The compiler will later emit
44
+ * IR-aligned ids (SR3); until then ids are runtime-assigned counters.
45
+ */
46
+ export interface ProfilerEventSink {
47
+ /** A signal's value changed (post `Object.is` bail). `batched` = inside `batch()`. */
48
+ signalSet(signalId: string, batched: boolean): void
49
+ /** A subscriber began reading a signal (dependency edge added). */
50
+ subscribeAdd(signalId: string, subscriberId: string): void
51
+ /** A subscriber stopped reading a signal (edge removed on re-run / dispose). */
52
+ subscribeRemove(signalId: string, subscriberId: string): void
53
+ /** An effect / memo / root scope was created. */
54
+ effectCreate(subscriberId: string, kind: SubscriberKind): void
55
+ /** An effect/memo body is about to run. */
56
+ effectEnter(subscriberId: string): void
57
+ /** An effect/memo body finished. `durationMs` is wall time. */
58
+ effectExit(subscriberId: string, durationMs: number): void
59
+ /**
60
+ * An effect/memo run produced an output fingerprint (#1690, §4.2.2).
61
+ * `changed` is `true` when the run produced new output (a memo value that
62
+ * differs by `Object.is`, or a DOM write that changed the node) and `false`
63
+ * when it recomputed but produced output identical to its previous run — a
64
+ * *wasted* re-run. Emitted at most once per run, only for runs whose output
65
+ * is fingerprintable (memo recompute / instrumented DOM write); a run that
66
+ * reports no output emits no event and isn't counted as wasted.
67
+ *
68
+ * Optional: it was added after the initial sink contract, so a pre-existing
69
+ * custom sink that omits it stays valid (the call site guards with `?.`). A
70
+ * sink without it simply opts out of the wasted-re-runs analysis.
71
+ */
72
+ effectOutput?(subscriberId: string, changed: boolean): void
73
+ /** An effect / memo / root scope was disposed. */
74
+ effectDispose(subscriberId: string): void
75
+ /** A `batch()` block opened at the given (post-increment) depth. */
76
+ batchBegin(depth: number): void
77
+ /** A batch flush ran `flushed` effects. */
78
+ batchFlush(flushed: number): void
79
+ /**
80
+ * A user interaction (one event handler invocation) began. Compiler-emitted
81
+ * `beginTurn(...)` wraps the handler body so subsequent events in this turn
82
+ * are attributed to `handlerId`. `loc` is the optional source location.
83
+ */
84
+ turnBegin(handlerId: string, loc?: string): void
85
+ /** The current turn ended (handler returned). */
86
+ turnEnd(): void
87
+ }
88
+
89
+ /** A signal's subscriber set, tagged with the signal's id for edge-removal events. */
90
+ type SubscriberSet = Set<EffectContext> & { __bfSignalId?: string }
91
+
92
+ let profilerSink: ProfilerEventSink | null = null
93
+ let signalSeq = 0
94
+ let subscriberSeq = 0
95
+
96
+ /**
97
+ * Install (or clear) the dev-only reactive measurement sink. Pass `null` to
98
+ * disable. Calling this before a scenario runs lets `bf debug profile` collect
99
+ * the event stream; production code never calls it, so the sink stays null and
100
+ * the choke points stay free (dev-only instrumentation, #1690).
101
+ */
102
+ export function setProfilerSink(sink: ProfilerEventSink | null): void {
103
+ profilerSink = sink
104
+ }
105
+
106
+ /**
107
+ * Mark the start of a user-interaction turn (#1690, SR3). Compiler-emitted in
108
+ * profile mode at every event-handler boundary as
109
+ * `beginTurn(handlerId, loc); try { … } finally { endTurn() }`. Measurement
110
+ * only — it does not change `set()`'s synchronous semantics; it just stamps a
111
+ * turn onto the events emitted between begin and end. No-op when profiling is
112
+ * off.
113
+ */
114
+ export function beginTurn(handlerId: string, loc?: string): void {
115
+ if (profilerSink) profilerSink.turnBegin(handlerId, loc)
116
+ }
117
+
118
+ /** Mark the end of the current interaction turn (#1690, SR3). */
119
+ export function endTurn(): void {
120
+ if (profilerSink) profilerSink.turnEnd()
121
+ }
122
+
123
+ /**
124
+ * Report the current run's output fingerprint (#1690, §4.2.2). Called from the
125
+ * places that produce an effect's observable output — a memo recompute (the
126
+ * written value's `Object.is` identity) and instrumented DOM writes (whether the
127
+ * node actually changed). Accumulated onto the running effect (`Owner`) and
128
+ * flushed as one `effectOutput` event at run exit, so several writes in one run
129
+ * collapse to a single "did this run change anything" verdict.
130
+ *
131
+ * No-op when profiling is off or called outside a run — the wasted-re-runs
132
+ * analysis is the only consumer and it lives behind the dev-only sink (SR8).
133
+ */
134
+ export function __bfReportOutput(changed: boolean): void {
135
+ if (!profilerSink || !Owner) return
136
+ Owner.outputReported = true
137
+ if (changed) Owner.outputChanged = true
138
+ }
139
+
26
140
  type EffectContext = {
27
141
  fn: EffectFn
28
142
  cleanup: CleanupFn | null
29
- dependencies: Set<Set<EffectContext>>
143
+ dependencies: Set<SubscriberSet>
30
144
  owner: EffectContext | null // Parent scope for hierarchical disposal
31
145
  children: EffectContext[] // Owned child effects/roots
32
146
  disposed: boolean
33
147
  runCount: number // Per-effect re-entry counter for circular dependency detection
148
+ id: string // Dev instrumentation id ('' when profiling is off)
149
+ kind: SubscriberKind // effect | memo | root
150
+ // Per-run output fingerprint accumulator (SR1, §4.2.2). Reset at the start of
151
+ // every run; set by `__bfReportOutput` (memo recompute / DOM write) during the
152
+ // body; flushed as one `effectOutput` event at exit. Dev-only — untouched when
153
+ // profiling is off.
154
+ outputReported: boolean
155
+ outputChanged: boolean
34
156
  }
35
157
 
36
158
  let Owner: EffectContext | null = null
@@ -52,14 +174,19 @@ const PendingEffects = new Set<EffectContext>()
52
174
  * setCount(5) // Update to 5
53
175
  * setCount(n => n + 1) // Update with function (becomes 6)
54
176
  */
55
- export function createSignal<T>(initialValue: T): Signal<T> {
177
+ export function createSignal<T>(initialValue: T, __bfId?: string): Signal<T> {
56
178
  let value = initialValue
57
- const subscribers = new Set<EffectContext>()
179
+ const subscribers: SubscriberSet = new Set<EffectContext>()
180
+ // Tag the subscriber set so edge-removal events (which only see the set) can
181
+ // name the signal. Resolved once per creation; '' when profiling is off.
182
+ const id = __bfId ?? (profilerSink ? `s${++signalSeq}` : '')
183
+ subscribers.__bfSignalId = id
58
184
 
59
185
  const get = () => {
60
186
  if (Listener) {
61
187
  subscribers.add(Listener)
62
188
  Listener.dependencies.add(subscribers)
189
+ if (profilerSink) profilerSink.subscribeAdd(id, Listener.id)
63
190
  }
64
191
  return value
65
192
  }
@@ -75,6 +202,8 @@ export function createSignal<T>(initialValue: T): Signal<T> {
75
202
 
76
203
  value = newValue
77
204
 
205
+ if (profilerSink) profilerSink.signalSet(id, BatchDepth > 0)
206
+
78
207
  if (BatchDepth > 0) {
79
208
  for (const effect of subscribers) {
80
209
  PendingEffects.add(effect)
@@ -102,7 +231,7 @@ export function createSignal<T>(initialValue: T): Signal<T> {
102
231
  * })
103
232
  * setCount(1) // Logs "count changed: 1"
104
233
  */
105
- export function createEffect(fn: EffectFn): void {
234
+ export function createEffect(fn: EffectFn, __bfId?: string, __bfKind: SubscriberKind = 'effect'): void {
106
235
  // Note: Nested effects are now allowed. runEffect() properly saves/restores
107
236
  // prevEffect, so nested effects correctly track their own dependencies.
108
237
  // This enables synchronous component initialization in reconcileList.
@@ -115,8 +244,14 @@ export function createEffect(fn: EffectFn): void {
115
244
  children: [],
116
245
  disposed: false,
117
246
  runCount: 0,
247
+ id: __bfId ?? (profilerSink ? `e${++subscriberSeq}` : ''),
248
+ kind: __bfKind,
249
+ outputReported: false,
250
+ outputChanged: false,
118
251
  }
119
252
 
253
+ if (profilerSink) profilerSink.effectCreate(effect.id, effect.kind)
254
+
120
255
  // Register with parent owner for hierarchical disposal
121
256
  if (Owner) Owner.children.push(effect)
122
257
 
@@ -132,6 +267,14 @@ function runEffect(effect: EffectContext): void {
132
267
  throw new Error(`Circular dependency detected: effect re-entered itself ${MAX_EFFECT_RUNS} times.`)
133
268
  }
134
269
 
270
+ // `effectEnter` is emitted *before* cleanup so the whole run — cleanup
271
+ // included — is bracketed by enter/exit. A `set()` performed inside a cleanup
272
+ // is then recorded at effect-stack depth > 0 (a cascade write) rather than
273
+ // depth 0 (a direct handler write), which keeps the batch advisor's write
274
+ // gate sound (#1865). Dev-only: in production `profilerSink` is null and this
275
+ // is a no-op, so the run is byte-identical to the un-instrumented path.
276
+ if (profilerSink) profilerSink.effectEnter(effect.id)
277
+
135
278
  if (effect.cleanup) {
136
279
  effect.cleanup()
137
280
  effect.cleanup = null
@@ -139,6 +282,7 @@ function runEffect(effect: EffectContext): void {
139
282
 
140
283
  for (const dep of effect.dependencies) {
141
284
  dep.delete(effect)
285
+ if (profilerSink) profilerSink.subscribeRemove(dep.__bfSignalId ?? '', effect.id)
142
286
  }
143
287
  effect.dependencies.clear()
144
288
 
@@ -147,6 +291,14 @@ function runEffect(effect: EffectContext): void {
147
291
  Owner = effect
148
292
  Listener = effect
149
293
 
294
+ // Fresh output fingerprint for this run (§4.2.2); `__bfReportOutput` fills it.
295
+ effect.outputReported = false
296
+ effect.outputChanged = false
297
+
298
+ // `start` stays here (after cleanup) so the reported duration measures the
299
+ // effect body only, unchanged by moving `effectEnter` above.
300
+ const start = profilerSink ? performance.now() : 0
301
+
150
302
  try {
151
303
  const result = effect.fn()
152
304
  if (typeof result === 'function') {
@@ -156,6 +308,10 @@ function runEffect(effect: EffectContext): void {
156
308
  Owner = prevOwner
157
309
  Listener = prevListener
158
310
  effect.runCount--
311
+ if (profilerSink) {
312
+ profilerSink.effectExit(effect.id, performance.now() - start)
313
+ if (effect.outputReported) profilerSink.effectOutput?.(effect.id, effect.outputChanged)
314
+ }
159
315
  }
160
316
  }
161
317
 
@@ -181,9 +337,12 @@ function disposeSubtree(effect: EffectContext): void {
181
337
 
182
338
  for (const dep of effect.dependencies) {
183
339
  dep.delete(effect)
340
+ if (profilerSink) profilerSink.subscribeRemove(dep.__bfSignalId ?? '', effect.id)
184
341
  }
185
342
  effect.dependencies.clear()
186
343
 
344
+ if (profilerSink) profilerSink.effectDispose(effect.id)
345
+
187
346
  effect.owner = null
188
347
  }
189
348
 
@@ -226,8 +385,14 @@ export function createRoot<T>(fn: (dispose: () => void) => T): T {
226
385
  children: [],
227
386
  disposed: false,
228
387
  runCount: 0,
388
+ id: profilerSink ? `r${++subscriberSeq}` : '',
389
+ kind: 'root',
390
+ outputReported: false,
391
+ outputChanged: false,
229
392
  }
230
393
 
394
+ if (profilerSink) profilerSink.effectCreate(root.id, 'root')
395
+
231
396
  if (Owner) Owner.children.push(root)
232
397
 
233
398
  const prevOwner = Owner
@@ -249,7 +414,7 @@ export function createRoot<T>(fn: (dispose: () => void) => T): T {
249
414
  *
250
415
  * @returns A dispose function that stops the effect and removes it from all signal dependencies.
251
416
  */
252
- export function createDisposableEffect(fn: EffectFn): () => void {
417
+ export function createDisposableEffect(fn: EffectFn, __bfId?: string): () => void {
253
418
  let disposed = false
254
419
 
255
420
  const effect: EffectContext = {
@@ -263,8 +428,14 @@ export function createDisposableEffect(fn: EffectFn): () => void {
263
428
  children: [],
264
429
  disposed: false,
265
430
  runCount: 0,
431
+ id: __bfId ?? (profilerSink ? `e${++subscriberSeq}` : ''),
432
+ kind: 'effect',
433
+ outputReported: false,
434
+ outputChanged: false,
266
435
  }
267
436
 
437
+ if (profilerSink) profilerSink.effectCreate(effect.id, effect.kind)
438
+
268
439
  if (Owner) Owner.children.push(effect)
269
440
 
270
441
  runEffect(effect)
@@ -342,6 +513,7 @@ export function untrack<T>(fn: () => T): T {
342
513
  */
343
514
  export function batch<T>(fn: () => T): T {
344
515
  BatchDepth++
516
+ if (profilerSink) profilerSink.batchBegin(BatchDepth)
345
517
  try {
346
518
  return fn()
347
519
  } finally {
@@ -356,6 +528,7 @@ function flushEffects(): void {
356
528
  while (PendingEffects.size > 0) {
357
529
  const effects = [...PendingEffects]
358
530
  PendingEffects.clear()
531
+ if (profilerSink) profilerSink.batchFlush(effects.length)
359
532
  for (const effect of effects) {
360
533
  runEffect(effect)
361
534
  }
@@ -398,13 +571,28 @@ export function onMount(fn: () => void): void {
398
571
  * setCount(5)
399
572
  * doubled() // 10
400
573
  */
401
- export function createMemo<T>(fn: () => T): Memo<T> {
402
- const [value, setValue] = createSignal<T>(undefined as T)
574
+ export function createMemo<T>(fn: () => T, __bfId?: string): Memo<T> {
575
+ // A memo is an effect that writes a private signal. Share one id across both
576
+ // so the profiler's IR join can collapse the effect-run + signal-set pair
577
+ // back into a single memo node (#1690).
578
+ const id = __bfId ?? (profilerSink ? `m${++subscriberSeq}` : '')
579
+ const [value, setValue] = createSignal<T>(undefined as T, id)
580
+
581
+ // Memo output fingerprint (§4.2.2): a recompute that yields an `Object.is`-equal
582
+ // value is a wasted re-run. Tracked here (not via the private signal's bail)
583
+ // so the first run reads as a genuine output, never as "unchanged".
584
+ let prev: T
585
+ let hasPrev = false
403
586
 
404
587
  createEffect(() => {
405
588
  const result = fn()
589
+ if (profilerSink) {
590
+ __bfReportOutput(!hasPrev || !Object.is(prev, result))
591
+ prev = result
592
+ hasPrev = true
593
+ }
406
594
  setValue(() => result)
407
- })
595
+ }, id, 'memo')
408
596
 
409
597
  return value
410
598
  }
@@ -10,6 +10,11 @@
10
10
  * returned by `createComponent`. Stringifying it produced
11
11
  * `"[object HTMLElement]"` (and clobbered the server-rendered subtree).
12
12
  *
13
+ * Profiler note (#1690, §4.2.2): each write reports an output fingerprint via
14
+ * `__bfReportOutput` — `false` when the slot already held the same text/node, so
15
+ * the wasted-re-runs analysis can flag a text binding that re-ran without
16
+ * changing the DOM. Dev-only: `__bfReportOutput` is a no-op when profiling is off.
17
+ *
13
18
  * `__bfText` mirrors `__bfSlot` (the branch-template equivalent): when the
14
19
  * value is a `Node`, it replaces the slot region with that node by identity;
15
20
  * otherwise it behaves exactly like the previous text assignment. It returns
@@ -17,22 +22,32 @@
17
22
  * reactive re-runs (the previous node is detached once replaced).
18
23
  */
19
24
 
25
+ import { __bfReportOutput } from '@barefootjs/client/reactive'
26
+
20
27
  const END_MARKER = '/'
21
28
 
22
29
  /** Remove every sibling between `start` (the `<!--bf:sX-->` comment) and the
23
30
  * matching `<!--/-->` end comment, leaving both markers in place. When `keep`
24
31
  * is supplied that node is left in place (used when writing a primitive
25
- * through a text anchor that must survive while stale siblings are cleared). */
26
- function clearSlotRegion(start: Node, keep?: Node): void {
32
+ * through a text anchor that must survive while stale siblings are cleared).
33
+ * Returns whether it actually removed any node, so the profiler fingerprint
34
+ * (§4.2.2) can treat a stale-element cleanup as a real DOM change even when the
35
+ * written text is unchanged. */
36
+ function clearSlotRegion(start: Node, keep?: Node): boolean {
37
+ let removed = false
27
38
  let n = start.nextSibling
28
39
  while (
29
40
  n &&
30
41
  !(n.nodeType === Node.COMMENT_NODE && (n as Comment).nodeValue === END_MARKER)
31
42
  ) {
32
43
  const next = n.nextSibling
33
- if (n !== keep) n.parentNode?.removeChild(n)
44
+ if (n !== keep) {
45
+ n.parentNode?.removeChild(n)
46
+ removed = true
47
+ }
34
48
  n = next
35
49
  }
50
+ return removed
36
51
  }
37
52
 
38
53
  /** Walk back from `node` to the nearest preceding comment marker (the slot's
@@ -49,8 +64,12 @@ export function __bfText(current: Node | null, value: unknown): Node | null {
49
64
  if (value != null && (value as { __isSlot?: boolean }).__isSlot) return current
50
65
 
51
66
  if (typeof Node !== 'undefined' && value instanceof Node) {
52
- if (value === current) return current
67
+ if (value === current) {
68
+ __bfReportOutput(false) // same node already in the slot — nothing changed
69
+ return current
70
+ }
53
71
  const start = current.previousSibling
72
+ __bfReportOutput(true)
54
73
  if (start && start.nodeType === Node.COMMENT_NODE) {
55
74
  clearSlotRegion(start)
56
75
  start.parentNode?.insertBefore(value, start.nextSibling)
@@ -63,6 +82,7 @@ export function __bfText(current: Node | null, value: unknown): Node | null {
63
82
 
64
83
  const text = String(value ?? '')
65
84
  if (current.nodeType === Node.TEXT_NODE) {
85
+ const textChanged = current.nodeValue !== text
66
86
  current.nodeValue = text
67
87
  // The conditional-slot path re-resolves the anchor via `$t()` on every
68
88
  // run, which can hand back a freshly created text node sitting *before* a
@@ -70,12 +90,17 @@ export function __bfText(current: Node | null, value: unknown): Node | null {
70
90
  // siblings up to the end marker so switching JSX → text doesn't render
71
91
  // both the old element and the new text.
72
92
  const start = slotStart(current)
73
- if (start && start.nodeType === Node.COMMENT_NODE) clearSlotRegion(start, current)
93
+ const clearedStale =
94
+ start != null && start.nodeType === Node.COMMENT_NODE && clearSlotRegion(start, current)
95
+ // Removing a stale element is a real DOM change even when the text matched,
96
+ // so the run isn't wasted in that case (§4.2.2).
97
+ __bfReportOutput(textChanged || clearedStale)
74
98
  return current
75
99
  }
76
100
 
77
101
  // Switching back from a Node value to text: drop the element and restore a
78
102
  // text node in the slot region.
103
+ __bfReportOutput(true)
79
104
  const start = current.previousSibling
80
105
  const textNode = (current.ownerDocument ?? document).createTextNode(text)
81
106
  if (start && start.nodeType === Node.COMMENT_NODE) {
@@ -18,13 +18,26 @@ export {
18
18
  onMount,
19
19
  untrack,
20
20
  batch,
21
+ setProfilerSink,
22
+ beginTurn,
23
+ endTurn,
24
+ __bfReportOutput,
21
25
  type Reactive,
22
26
  type Signal,
23
27
  type Memo,
24
28
  type CleanupFn,
25
29
  type EffectFn,
30
+ type ProfilerEventSink,
31
+ type SubscriberKind,
26
32
  } from '@barefootjs/client/reactive'
27
33
 
34
+ export {
35
+ createRecordingSink,
36
+ type ProfilerEvent,
37
+ type ProfilerEventType,
38
+ type RecordingSink,
39
+ } from '../profiler-events.ts'
40
+
28
41
  export { splitProps } from '../split-props.ts'
29
42
  export { __slot, type SlotMarker } from '../slot.ts'
30
43
  export { forwardProps } from '../forward-props.ts'
@@ -182,7 +182,8 @@ export function insert(
182
182
  id: string,
183
183
  conditionFn: () => boolean,
184
184
  whenTrue: BranchConfig,
185
- whenFalse: BranchConfig
185
+ whenFalse: BranchConfig,
186
+ bfId?: string
186
187
  ): void {
187
188
  if (!scope) return
188
189
 
@@ -325,7 +326,7 @@ export function insert(
325
326
 
326
327
  // Auto-focus elements with autofocus attribute (for dynamically created elements)
327
328
  autoFocusConditionalElement(region, id)
328
- })
329
+ }, bfId)
329
330
  }
330
331
 
331
332
 
@@ -222,6 +222,7 @@ export function mapArray<T>(
222
222
  getKey: ((item: T, index: number) => string) | null,
223
223
  renderItem: (item: () => T, index: number, existing?: HTMLElement) => HTMLElement,
224
224
  markerId?: string,
225
+ bfId?: string,
225
226
  ): void {
226
227
  if (!container) return
227
228
 
@@ -389,7 +390,7 @@ export function mapArray<T>(
389
390
  insertScope(scope, container, anchor)
390
391
  }
391
392
  }
392
- })
393
+ }, bfId)
393
394
  }
394
395
 
395
396
  // ---------------------------------------------------------------------------
@@ -521,6 +522,7 @@ export function mapArrayAnchored<T>(
521
522
  getKey: ((item: T, index: number) => string) | null,
522
523
  renderItem: (item: () => T, index: number, existing?: Comment) => DocumentFragment | Comment,
523
524
  markerId?: string,
525
+ bfId?: string,
524
526
  ): void {
525
527
  if (!container) return
526
528
 
@@ -615,5 +617,5 @@ export function mapArrayAnchored<T>(
615
617
  placeAnchorScope(scope, container, end, end)
616
618
  }
617
619
  }
618
- })
620
+ }, bfId)
619
621
  }