@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.
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +53 -2
- package/dist/profiler-events.d.ts +36 -0
- package/dist/profiler-events.d.ts.map +1 -0
- package/dist/reactive.d.ts +84 -4
- package/dist/reactive.d.ts.map +1 -1
- package/dist/reactive.js +87 -10
- package/dist/runtime/dynamic-text.d.ts +5 -0
- package/dist/runtime/dynamic-text.d.ts.map +1 -1
- package/dist/runtime/index.d.ts +2 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +72 -11
- package/dist/runtime/insert.d.ts +1 -1
- package/dist/runtime/insert.d.ts.map +1 -1
- package/dist/runtime/map-array.d.ts +2 -2
- package/dist/runtime/map-array.d.ts.map +1 -1
- package/dist/runtime/standalone.js +148 -19
- package/package.json +2 -2
- package/src/index.ts +13 -0
- package/src/profiler-events.ts +89 -0
- package/src/reactive.ts +196 -8
- package/src/runtime/dynamic-text.ts +30 -5
- package/src/runtime/index.ts +13 -0
- package/src/runtime/insert.ts +3 -2
- package/src/runtime/map-array.ts +4 -2
|
@@ -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<
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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) {
|
package/src/runtime/index.ts
CHANGED
|
@@ -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'
|
package/src/runtime/insert.ts
CHANGED
|
@@ -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
|
|
package/src/runtime/map-array.ts
CHANGED
|
@@ -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
|
}
|