@barefootjs/jsx 0.2.0 → 0.4.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/debug.d.ts +78 -2
- package/dist/debug.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +331 -15
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/auto-defer-brand.test.ts +284 -0
- package/src/__tests__/debug.test.ts +346 -1
- package/src/debug.ts +450 -20
- package/src/index.ts +10 -1
- package/src/ir-to-client-js/html-template.ts +17 -0
- package/src/jsx-to-ir.ts +39 -2
package/src/debug.ts
CHANGED
|
@@ -139,7 +139,14 @@ export interface EventBinding {
|
|
|
139
139
|
export interface SetterRef {
|
|
140
140
|
setter: string
|
|
141
141
|
signal: string | null
|
|
142
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Call chain from the handler to the setter, when the setter is reached
|
|
144
|
+
* through one or more local helper functions. For a handler that calls
|
|
145
|
+
* the setter directly this is omitted. For `onClick={handlePointerDown}`
|
|
146
|
+
* where `handlePointerDown` calls `setValue` which calls `setInternalValue`,
|
|
147
|
+
* this is `['handlePointerDown', 'setValue']`.
|
|
148
|
+
*/
|
|
149
|
+
via?: string[]
|
|
143
150
|
}
|
|
144
151
|
|
|
145
152
|
export interface EventSummary {
|
|
@@ -175,6 +182,49 @@ export interface LoopSummary {
|
|
|
175
182
|
loops: LoopInfo[]
|
|
176
183
|
}
|
|
177
184
|
|
|
185
|
+
// -- Why-update analysis types ------------------------------------------------
|
|
186
|
+
|
|
187
|
+
export interface WhyUpdateResult {
|
|
188
|
+
binding: string
|
|
189
|
+
expression: string | null
|
|
190
|
+
deps: WhyUpdateDep[]
|
|
191
|
+
classification?: 'reactive' | 'fallback'
|
|
192
|
+
wrapReason?: WrapReason
|
|
193
|
+
ambiguous?: Array<{ label: string; slotId: string }>
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface WhyUpdateDep {
|
|
197
|
+
name: string
|
|
198
|
+
kind: 'signal' | 'memo'
|
|
199
|
+
dependsOn: string[]
|
|
200
|
+
changedBy: WhyUpdateSource[]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface WhyUpdateSource {
|
|
204
|
+
handler: string
|
|
205
|
+
setter: string
|
|
206
|
+
elementContext: string
|
|
207
|
+
via?: string[]
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// -- Component summary types --------------------------------------------------
|
|
211
|
+
|
|
212
|
+
export interface ComponentSummary {
|
|
213
|
+
componentName: string
|
|
214
|
+
sourceFile: string
|
|
215
|
+
hydrated: boolean
|
|
216
|
+
clientBundle: string | null
|
|
217
|
+
signals: number
|
|
218
|
+
memos: number
|
|
219
|
+
effects: number
|
|
220
|
+
loops: number
|
|
221
|
+
eventHandlers: number
|
|
222
|
+
dynamicTextBindings: number
|
|
223
|
+
dynamicAttributes: number
|
|
224
|
+
conditionals: number
|
|
225
|
+
fallbacks: number
|
|
226
|
+
}
|
|
227
|
+
|
|
178
228
|
// -- Component analysis (shared IR + graph) -----------------------------------
|
|
179
229
|
|
|
180
230
|
export interface ComponentAnalysis {
|
|
@@ -379,11 +429,11 @@ export function buildEventSummary(source: string, filePath: string, componentNam
|
|
|
379
429
|
}
|
|
380
430
|
}
|
|
381
431
|
|
|
382
|
-
function escapeForIdBoundary(name: string): string {
|
|
432
|
+
export function escapeForIdBoundary(name: string): string {
|
|
383
433
|
return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
384
434
|
}
|
|
385
435
|
|
|
386
|
-
function makeIdCallRegex(name: string): RegExp {
|
|
436
|
+
export function makeIdCallRegex(name: string): RegExp {
|
|
387
437
|
return new RegExp(`(?:^|[^\\w$])${escapeForIdBoundary(name)}\\s*\\(`)
|
|
388
438
|
}
|
|
389
439
|
|
|
@@ -391,26 +441,87 @@ function makeIdRefRegex(name: string): RegExp {
|
|
|
391
441
|
return new RegExp(`(?:^|[^\\w$])${escapeForIdBoundary(name)}(?:[^\\w$]|$)`)
|
|
392
442
|
}
|
|
393
443
|
|
|
394
|
-
|
|
444
|
+
/**
|
|
445
|
+
* A setter reachable from a local function, with the chain of intermediate
|
|
446
|
+
* function names between that function and the setter (excluding the function
|
|
447
|
+
* itself). For a direct call the chain is empty.
|
|
448
|
+
*/
|
|
449
|
+
export interface FnSetterResolution {
|
|
450
|
+
setter: string
|
|
451
|
+
chain: string[]
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function buildLocalFunctionSetterMap(
|
|
395
455
|
meta: IRMetadata,
|
|
396
456
|
setterToSignal: Map<string, string>,
|
|
397
|
-
): Map<string,
|
|
457
|
+
): Map<string, FnSetterResolution[]> {
|
|
398
458
|
const setterPatterns = [...setterToSignal.keys()].map(s => ({ name: s, re: makeIdCallRegex(s) }))
|
|
399
|
-
|
|
400
|
-
|
|
459
|
+
|
|
460
|
+
// Collect every local function-like binding: `function foo() {}` declarations
|
|
461
|
+
// plus arrow/function-expression consts (`const foo = () => {}`), which land
|
|
462
|
+
// in localConstants rather than localFunctions.
|
|
463
|
+
const bodies = new Map<string, string>()
|
|
464
|
+
for (const fn of meta.localFunctions) bodies.set(fn.name, fn.body)
|
|
465
|
+
for (const c of meta.localConstants) {
|
|
466
|
+
if (c.containsArrow && c.value) bodies.set(c.name, c.value)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Direct setters and direct local-function calls per binding.
|
|
470
|
+
const fnNamePatterns = [...bodies.keys()].map(n => ({ name: n, re: makeIdCallRegex(n) }))
|
|
471
|
+
const directSetters = new Map<string, string[]>()
|
|
472
|
+
const directCalls = new Map<string, string[]>()
|
|
473
|
+
for (const [name, body] of bodies) {
|
|
401
474
|
const setters: string[] = []
|
|
402
|
-
for (const { name, re } of setterPatterns) {
|
|
403
|
-
if (re.test(
|
|
475
|
+
for (const { name: setter, re } of setterPatterns) {
|
|
476
|
+
if (re.test(body)) setters.push(setter)
|
|
477
|
+
}
|
|
478
|
+
directSetters.set(name, setters)
|
|
479
|
+
const calls: string[] = []
|
|
480
|
+
for (const { name: callee, re } of fnNamePatterns) {
|
|
481
|
+
if (callee !== name && re.test(body)) calls.push(callee)
|
|
482
|
+
}
|
|
483
|
+
directCalls.set(name, calls)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Resolve transitively: a handler may reach a setter through a chain of
|
|
487
|
+
// helper functions (handler -> setValue -> setInternalValue). `stack`
|
|
488
|
+
// guards against cycles (mutual recursion between helpers). Component
|
|
489
|
+
// helper graphs are tiny, so a plain DFS per binding is fine.
|
|
490
|
+
const resolve = (name: string, stack: Set<string>): FnSetterResolution[] => {
|
|
491
|
+
const out: FnSetterResolution[] = []
|
|
492
|
+
const seen = new Set<string>()
|
|
493
|
+
for (const setter of directSetters.get(name) ?? []) {
|
|
494
|
+
if (!seen.has(setter)) {
|
|
495
|
+
out.push({ setter, chain: [] })
|
|
496
|
+
seen.add(setter)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
for (const callee of directCalls.get(name) ?? []) {
|
|
500
|
+
if (stack.has(callee)) continue
|
|
501
|
+
const sub = resolve(callee, new Set([...stack, callee]))
|
|
502
|
+
for (const r of sub) {
|
|
503
|
+
if (!seen.has(r.setter)) {
|
|
504
|
+
out.push({ setter: r.setter, chain: [callee, ...r.chain] })
|
|
505
|
+
seen.add(r.setter)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
404
508
|
}
|
|
405
|
-
|
|
509
|
+
return out
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const result = new Map<string, FnSetterResolution[]>()
|
|
513
|
+
for (const name of bodies.keys()) {
|
|
514
|
+
const resolved = resolve(name, new Set([name]))
|
|
515
|
+
if (resolved.length > 0) result.set(name, resolved)
|
|
406
516
|
}
|
|
517
|
+
|
|
407
518
|
return result
|
|
408
519
|
}
|
|
409
520
|
|
|
410
521
|
function collectEventBindings(
|
|
411
522
|
node: IRNode,
|
|
412
523
|
setterToSignal: Map<string, string>,
|
|
413
|
-
fnSetters: Map<string,
|
|
524
|
+
fnSetters: Map<string, FnSetterResolution[]>,
|
|
414
525
|
): EventBinding[] {
|
|
415
526
|
const events: EventBinding[] = []
|
|
416
527
|
walkForEvents(node, events, setterToSignal, fnSetters)
|
|
@@ -421,7 +532,7 @@ function walkForEvents(
|
|
|
421
532
|
node: IRNode,
|
|
422
533
|
events: EventBinding[],
|
|
423
534
|
setterToSignal: Map<string, string>,
|
|
424
|
-
fnSetters: Map<string,
|
|
535
|
+
fnSetters: Map<string, FnSetterResolution[]>,
|
|
425
536
|
): void {
|
|
426
537
|
switch (node.type) {
|
|
427
538
|
case 'element': {
|
|
@@ -494,10 +605,10 @@ function walkForEvents(
|
|
|
494
605
|
}
|
|
495
606
|
}
|
|
496
607
|
|
|
497
|
-
function resolveSetters(
|
|
608
|
+
export function resolveSetters(
|
|
498
609
|
handler: string,
|
|
499
610
|
setterToSignal: Map<string, string>,
|
|
500
|
-
fnSetters: Map<string,
|
|
611
|
+
fnSetters: Map<string, FnSetterResolution[]>,
|
|
501
612
|
): SetterRef[] {
|
|
502
613
|
const refs: SetterRef[] = []
|
|
503
614
|
const seen = new Set<string>()
|
|
@@ -512,12 +623,16 @@ function resolveSetters(
|
|
|
512
623
|
}
|
|
513
624
|
}
|
|
514
625
|
|
|
515
|
-
for (const [fnName,
|
|
626
|
+
for (const [fnName, resolutions] of fnSetters) {
|
|
516
627
|
if (trimmed === fnName || makeIdCallRegex(fnName).test(handler)) {
|
|
517
|
-
for (const
|
|
518
|
-
if (!seen.has(setter)) {
|
|
519
|
-
refs.push({
|
|
520
|
-
|
|
628
|
+
for (const r of resolutions) {
|
|
629
|
+
if (!seen.has(r.setter)) {
|
|
630
|
+
refs.push({
|
|
631
|
+
setter: r.setter,
|
|
632
|
+
signal: setterToSignal.get(r.setter) ?? null,
|
|
633
|
+
via: [fnName, ...r.chain],
|
|
634
|
+
})
|
|
635
|
+
seen.add(r.setter)
|
|
521
636
|
}
|
|
522
637
|
}
|
|
523
638
|
}
|
|
@@ -562,7 +677,7 @@ export function formatEventSummary(summary: EventSummary, graph: ComponentGraph)
|
|
|
562
677
|
lines.push(` ${event.elementContext}`)
|
|
563
678
|
|
|
564
679
|
const setterParts = event.setterCalls.map(s => {
|
|
565
|
-
const chain = s.via ? `${s.via} -> ${s.setter}` : s.setter
|
|
680
|
+
const chain = s.via && s.via.length > 0 ? `${s.via.join(' -> ')} -> ${s.setter}` : s.setter
|
|
566
681
|
return chain
|
|
567
682
|
})
|
|
568
683
|
|
|
@@ -893,6 +1008,121 @@ function buildUpdateEntry(consumer: string, graph: ComponentGraph, visited: Set<
|
|
|
893
1008
|
return { name: consumer, kind: 'effect', label: consumer, children: [] }
|
|
894
1009
|
}
|
|
895
1010
|
|
|
1011
|
+
// =============================================================================
|
|
1012
|
+
// Analysis: Why-Update (binding → reason)
|
|
1013
|
+
// =============================================================================
|
|
1014
|
+
|
|
1015
|
+
export function buildWhyUpdate(
|
|
1016
|
+
source: string,
|
|
1017
|
+
filePath: string,
|
|
1018
|
+
bindingLabel: string,
|
|
1019
|
+
componentName?: string,
|
|
1020
|
+
): WhyUpdateResult | null {
|
|
1021
|
+
const { graph, ir } = buildComponentAnalysis(source, filePath, componentName)
|
|
1022
|
+
|
|
1023
|
+
const matches = graph.domBindings.filter(d =>
|
|
1024
|
+
d.label === bindingLabel ||
|
|
1025
|
+
d.slotId === bindingLabel,
|
|
1026
|
+
)
|
|
1027
|
+
if (matches.length === 0) return null
|
|
1028
|
+
if (matches.length > 1) {
|
|
1029
|
+
return {
|
|
1030
|
+
binding: bindingLabel,
|
|
1031
|
+
expression: null,
|
|
1032
|
+
deps: [],
|
|
1033
|
+
ambiguous: matches.map(d => ({ label: d.label, slotId: d.slotId })),
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
const binding = matches[0]
|
|
1037
|
+
|
|
1038
|
+
const setterToSignal = new Map<string, string>()
|
|
1039
|
+
for (const s of ir.metadata.signals) {
|
|
1040
|
+
if (s.setter) setterToSignal.set(s.setter, s.getter)
|
|
1041
|
+
}
|
|
1042
|
+
const fnSetters = buildLocalFunctionSetterMap(ir.metadata, setterToSignal)
|
|
1043
|
+
const events = collectEventBindings(ir.root, setterToSignal, fnSetters)
|
|
1044
|
+
|
|
1045
|
+
const deps: WhyUpdateDep[] = []
|
|
1046
|
+
const visited = new Set<string>()
|
|
1047
|
+
|
|
1048
|
+
function traceDep(name: string): void {
|
|
1049
|
+
if (visited.has(name)) return
|
|
1050
|
+
visited.add(name)
|
|
1051
|
+
|
|
1052
|
+
const signal = graph.signals.find(s => s.name === name)
|
|
1053
|
+
if (signal) {
|
|
1054
|
+
const changedBy: WhyUpdateSource[] = []
|
|
1055
|
+
for (const ev of events) {
|
|
1056
|
+
for (const sc of ev.setterCalls) {
|
|
1057
|
+
if (sc.signal === name) {
|
|
1058
|
+
changedBy.push({
|
|
1059
|
+
handler: ev.eventName,
|
|
1060
|
+
setter: sc.setter,
|
|
1061
|
+
elementContext: ev.elementContext,
|
|
1062
|
+
via: sc.via,
|
|
1063
|
+
})
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
deps.push({ name, kind: 'signal', dependsOn: [], changedBy })
|
|
1068
|
+
return
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const memo = graph.memos.find(m => m.name === name)
|
|
1072
|
+
if (memo) {
|
|
1073
|
+
deps.push({ name, kind: 'memo', dependsOn: memo.deps, changedBy: [] })
|
|
1074
|
+
for (const dep of memo.deps) traceDep(dep)
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
for (const dep of binding.deps) traceDep(dep)
|
|
1079
|
+
|
|
1080
|
+
const stableId = binding.type === 'attribute' ? binding.label : binding.slotId
|
|
1081
|
+
return {
|
|
1082
|
+
binding: stableId,
|
|
1083
|
+
expression: binding.expression ?? null,
|
|
1084
|
+
deps,
|
|
1085
|
+
...(binding.classification === 'fallback' && { classification: binding.classification as 'fallback' }),
|
|
1086
|
+
...(binding.wrapReason && { wrapReason: binding.wrapReason }),
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
export function formatWhyUpdate(result: WhyUpdateResult): string {
|
|
1091
|
+
const lines: string[] = []
|
|
1092
|
+
|
|
1093
|
+
lines.push(`${result.binding} updates because:`)
|
|
1094
|
+
if (result.expression) {
|
|
1095
|
+
lines.push(` ${result.expression}`)
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
if (result.classification === 'fallback') {
|
|
1099
|
+
lines.push('')
|
|
1100
|
+
lines.push(`note: this is a fallback-wrapped binding (${result.wrapReason ?? 'unknown'})`)
|
|
1101
|
+
lines.push(' the compiler could not statically prove reactivity — deps are determined at runtime')
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
for (const dep of result.deps) {
|
|
1105
|
+
lines.push('')
|
|
1106
|
+
if (dep.kind === 'memo') {
|
|
1107
|
+
lines.push(`${dep.name} depends on:`)
|
|
1108
|
+
for (const d of dep.dependsOn) lines.push(` ${d}`)
|
|
1109
|
+
} else {
|
|
1110
|
+
lines.push(`${dep.name} changes from:`)
|
|
1111
|
+
if (dep.changedBy.length === 0) {
|
|
1112
|
+
lines.push(' (no event handlers found)')
|
|
1113
|
+
}
|
|
1114
|
+
for (const src of dep.changedBy) {
|
|
1115
|
+
const chain = src.via && src.via.length > 0
|
|
1116
|
+
? `${src.elementContext} ${src.handler} -> ${src.via.join(' -> ')} -> ${src.setter}`
|
|
1117
|
+
: `${src.elementContext} ${src.handler} -> ${src.setter}`
|
|
1118
|
+
lines.push(` ${chain}`)
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
return lines.join('\n')
|
|
1124
|
+
}
|
|
1125
|
+
|
|
896
1126
|
// =============================================================================
|
|
897
1127
|
// Formatting: Human-readable output
|
|
898
1128
|
// =============================================================================
|
|
@@ -1132,6 +1362,206 @@ export function formatSignalTrace(traces: SignalTrace[]): string {
|
|
|
1132
1362
|
}).join('\n')
|
|
1133
1363
|
}
|
|
1134
1364
|
|
|
1365
|
+
// =============================================================================
|
|
1366
|
+
// Fallback explanation
|
|
1367
|
+
// =============================================================================
|
|
1368
|
+
|
|
1369
|
+
export interface FallbackExplanation {
|
|
1370
|
+
label: string
|
|
1371
|
+
expression: string
|
|
1372
|
+
reason: string
|
|
1373
|
+
runtimeDeps: string
|
|
1374
|
+
suggestion: string
|
|
1375
|
+
loc?: { file: string; line: number }
|
|
1376
|
+
isEventHandler: boolean
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
export function describeFallback(binding: DomBinding): FallbackExplanation {
|
|
1380
|
+
const isEventHandler = binding.type === 'event' ||
|
|
1381
|
+
(binding.type === 'attribute' && /^on[A-Z]/.test(binding.label.split('.').pop() ?? ''))
|
|
1382
|
+
|
|
1383
|
+
const reason = describeFallbackReason(binding.wrapReason, binding.type, isEventHandler)
|
|
1384
|
+
const runtimeDeps = binding.deps.length > 0
|
|
1385
|
+
? binding.deps.join(', ')
|
|
1386
|
+
: isEventHandler
|
|
1387
|
+
? 'likely none (event handler captures values, does not track reactively)'
|
|
1388
|
+
: 'unknown — subscribes to whatever signals it reads at runtime'
|
|
1389
|
+
|
|
1390
|
+
const suggestion = isEventHandler
|
|
1391
|
+
? 'event handlers intentionally capture scope values; this fallback is typically safe to ignore'
|
|
1392
|
+
: binding.wrapReason === 'fallback-function-calls'
|
|
1393
|
+
? 'inline the reactive source or wrap the result in createMemo so the compiler can prove the dependency'
|
|
1394
|
+
: binding.wrapReason === 'fallback-getter-calls'
|
|
1395
|
+
? 'the call looks like a signal getter but is not a known signal; verify the function is pure or extract as createMemo'
|
|
1396
|
+
: 'rewrite to use a known signal/memo reference so the compiler can statically prove reactivity'
|
|
1397
|
+
|
|
1398
|
+
return {
|
|
1399
|
+
label: binding.label,
|
|
1400
|
+
expression: binding.expression ?? '(expression not captured)',
|
|
1401
|
+
reason,
|
|
1402
|
+
runtimeDeps,
|
|
1403
|
+
suggestion,
|
|
1404
|
+
loc: binding.loc ? { file: binding.loc.file, line: binding.loc.start.line } : undefined,
|
|
1405
|
+
isEventHandler,
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function describeFallbackReason(
|
|
1410
|
+
wrapReason: WrapReason | undefined,
|
|
1411
|
+
bindingType: string,
|
|
1412
|
+
isEventHandler: boolean,
|
|
1413
|
+
): string {
|
|
1414
|
+
const context = bindingType === 'attribute'
|
|
1415
|
+
? 'an attribute expression'
|
|
1416
|
+
: bindingType === 'text'
|
|
1417
|
+
? 'a text interpolation'
|
|
1418
|
+
: bindingType === 'conditional'
|
|
1419
|
+
? 'a conditional expression'
|
|
1420
|
+
: bindingType === 'loop'
|
|
1421
|
+
? 'a loop array expression'
|
|
1422
|
+
: 'an expression'
|
|
1423
|
+
|
|
1424
|
+
switch (wrapReason) {
|
|
1425
|
+
case 'fallback-function-calls':
|
|
1426
|
+
return isEventHandler
|
|
1427
|
+
? `function call in ${context} (event handler prop)`
|
|
1428
|
+
: `opaque function call in ${context} — the compiler cannot prove it is reactive or pure`
|
|
1429
|
+
case 'fallback-getter-calls':
|
|
1430
|
+
return `call pattern resembles a signal getter in ${context}, but is not a recognized signal`
|
|
1431
|
+
case 'string-reactive':
|
|
1432
|
+
return `string-level match found a signal/memo name in ${context}`
|
|
1433
|
+
case 'props-access':
|
|
1434
|
+
return `props.xxx reference in ${context} — reactive via prop forwarding`
|
|
1435
|
+
case 'proven-reactive':
|
|
1436
|
+
return `statically proven reactive in ${context}`
|
|
1437
|
+
default:
|
|
1438
|
+
return `unknown fallback trigger in ${context}`
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
export function formatFallbackExplanations(
|
|
1443
|
+
componentName: string,
|
|
1444
|
+
fallbacks: DomBinding[],
|
|
1445
|
+
): string {
|
|
1446
|
+
const lines: string[] = []
|
|
1447
|
+
|
|
1448
|
+
if (fallbacks.length === 0) {
|
|
1449
|
+
lines.push(`${componentName} — no fallback-wrapped expressions.`)
|
|
1450
|
+
return lines.join('\n')
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
lines.push(`${componentName} — ${fallbacks.length} fallback-wrapped expression(s)`)
|
|
1454
|
+
|
|
1455
|
+
for (const f of fallbacks) {
|
|
1456
|
+
const ex = describeFallback(f)
|
|
1457
|
+
lines.push('')
|
|
1458
|
+
if (ex.loc) {
|
|
1459
|
+
const locFile = ex.loc.file.split('/').pop() ?? ex.loc.file
|
|
1460
|
+
lines.push(` ${locFile}:${ex.loc.line}`)
|
|
1461
|
+
}
|
|
1462
|
+
lines.push(` ${ex.label} fallback:`)
|
|
1463
|
+
lines.push(` expression: ${ex.expression}`)
|
|
1464
|
+
lines.push(` reason: ${ex.reason}`)
|
|
1465
|
+
lines.push(` runtime deps: ${ex.runtimeDeps}`)
|
|
1466
|
+
lines.push(` suggestion: ${ex.suggestion}`)
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
return lines.join('\n')
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// =============================================================================
|
|
1473
|
+
// Component Summary (hydration/size overview)
|
|
1474
|
+
// =============================================================================
|
|
1475
|
+
|
|
1476
|
+
export function buildComponentSummary(source: string, filePath: string, componentName?: string): ComponentSummary {
|
|
1477
|
+
const { graph, ir } = buildComponentAnalysis(source, filePath, componentName)
|
|
1478
|
+
const meta = ir.metadata
|
|
1479
|
+
const clientNeeds = analyzeClientNeeds(ir)
|
|
1480
|
+
const hasReactiveState = meta.signals.length > 0 || meta.memos.length > 0 || meta.effects.length > 0
|
|
1481
|
+
const needsClient = clientNeeds.needsInit && hasReactiveState
|
|
1482
|
+
|
|
1483
|
+
let loopCount = 0
|
|
1484
|
+
countNodeType(ir.root, 'loop', () => { loopCount++ })
|
|
1485
|
+
|
|
1486
|
+
let conditionalCount = 0
|
|
1487
|
+
countNodeType(ir.root, 'conditional', () => { conditionalCount++ })
|
|
1488
|
+
|
|
1489
|
+
const eventHandlers = graph.domBindings.filter(d => d.type === 'event').length
|
|
1490
|
+
const textBindings = graph.domBindings.filter(d => d.type === 'text').length
|
|
1491
|
+
const attrBindings = graph.domBindings.filter(d => d.type === 'attribute').length
|
|
1492
|
+
const fallbacks = graph.domBindings.filter(d => d.classification === 'fallback').length
|
|
1493
|
+
|
|
1494
|
+
let clientBundle: string | null = null
|
|
1495
|
+
if (needsClient) {
|
|
1496
|
+
const base = filePath.replace(/\.[^.]+$/, '').split('/').pop() ?? meta.componentName
|
|
1497
|
+
clientBundle = `${base}.client.js`
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
return {
|
|
1501
|
+
componentName: graph.componentName,
|
|
1502
|
+
sourceFile: graph.sourceFile,
|
|
1503
|
+
hydrated: needsClient,
|
|
1504
|
+
clientBundle,
|
|
1505
|
+
signals: graph.signals.length,
|
|
1506
|
+
memos: graph.memos.length,
|
|
1507
|
+
effects: graph.effects.length,
|
|
1508
|
+
loops: loopCount,
|
|
1509
|
+
eventHandlers,
|
|
1510
|
+
dynamicTextBindings: textBindings,
|
|
1511
|
+
dynamicAttributes: attrBindings,
|
|
1512
|
+
conditionals: conditionalCount,
|
|
1513
|
+
fallbacks,
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function countNodeType(node: IRNode, targetType: string, cb: () => void): void {
|
|
1518
|
+
if (node.type === targetType) cb()
|
|
1519
|
+
switch (node.type) {
|
|
1520
|
+
case 'element':
|
|
1521
|
+
case 'fragment':
|
|
1522
|
+
case 'provider':
|
|
1523
|
+
for (const child of node.children) countNodeType(child, targetType, cb)
|
|
1524
|
+
break
|
|
1525
|
+
case 'component':
|
|
1526
|
+
for (const child of node.children) countNodeType(child, targetType, cb)
|
|
1527
|
+
break
|
|
1528
|
+
case 'conditional':
|
|
1529
|
+
countNodeType(node.whenTrue, targetType, cb)
|
|
1530
|
+
countNodeType(node.whenFalse, targetType, cb)
|
|
1531
|
+
break
|
|
1532
|
+
case 'loop':
|
|
1533
|
+
for (const child of node.children) countNodeType(child, targetType, cb)
|
|
1534
|
+
break
|
|
1535
|
+
case 'if-statement':
|
|
1536
|
+
countNodeType(node.consequent, targetType, cb)
|
|
1537
|
+
if (node.alternate) countNodeType(node.alternate, targetType, cb)
|
|
1538
|
+
break
|
|
1539
|
+
case 'async':
|
|
1540
|
+
countNodeType(node.fallback, targetType, cb)
|
|
1541
|
+
for (const child of node.children) countNodeType(child, targetType, cb)
|
|
1542
|
+
break
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
export function formatComponentSummary(summary: ComponentSummary): string {
|
|
1547
|
+
const lines: string[] = []
|
|
1548
|
+
lines.push(summary.componentName)
|
|
1549
|
+
lines.push(` hydrated: ${summary.hydrated ? 'yes' : 'no'}`)
|
|
1550
|
+
if (summary.clientBundle) {
|
|
1551
|
+
lines.push(` client bundle: ${summary.clientBundle}`)
|
|
1552
|
+
}
|
|
1553
|
+
lines.push(` signals: ${summary.signals}`)
|
|
1554
|
+
lines.push(` memos: ${summary.memos}`)
|
|
1555
|
+
if (summary.effects > 0) lines.push(` effects: ${summary.effects}`)
|
|
1556
|
+
lines.push(` loops: ${summary.loops}`)
|
|
1557
|
+
lines.push(` event handlers: ${summary.eventHandlers}`)
|
|
1558
|
+
lines.push(` dynamic text bindings: ${summary.dynamicTextBindings}`)
|
|
1559
|
+
lines.push(` dynamic attributes: ${summary.dynamicAttributes}`)
|
|
1560
|
+
if (summary.conditionals > 0) lines.push(` conditionals: ${summary.conditionals}`)
|
|
1561
|
+
if (summary.fallbacks > 0) lines.push(` fallbacks: ${summary.fallbacks}`)
|
|
1562
|
+
return lines.join('\n')
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1135
1565
|
// =============================================================================
|
|
1136
1566
|
// Helpers
|
|
1137
1567
|
// =============================================================================
|
package/src/index.ts
CHANGED
|
@@ -250,16 +250,25 @@ export {
|
|
|
250
250
|
buildGraphFromIR,
|
|
251
251
|
buildEventSummary,
|
|
252
252
|
buildLoopSummary,
|
|
253
|
+
buildWhyUpdate,
|
|
253
254
|
traceUpdatePath,
|
|
254
255
|
formatComponentGraph,
|
|
255
256
|
formatUpdatePath,
|
|
256
257
|
formatEventSummary,
|
|
257
258
|
formatLoopSummary,
|
|
259
|
+
formatWhyUpdate,
|
|
260
|
+
describeFallback,
|
|
261
|
+
formatFallbackExplanations,
|
|
262
|
+
buildComponentSummary,
|
|
263
|
+
formatComponentSummary,
|
|
258
264
|
formatSignalTrace,
|
|
259
265
|
generateStaticTrace,
|
|
260
266
|
graphToJSON,
|
|
267
|
+
resolveSetters,
|
|
268
|
+
buildLocalFunctionSetterMap,
|
|
269
|
+
makeIdCallRegex,
|
|
261
270
|
} from './debug'
|
|
262
|
-
export type { ComponentGraph, ComponentAnalysis, SignalNode, MemoNode, EffectNode, DomBinding, UpdatePath, SignalTrace, EventBinding, SetterRef, EventSummary, LoopInfo, LoopChildBinding, LoopSummary } from './debug'
|
|
271
|
+
export type { ComponentGraph, ComponentAnalysis, SignalNode, MemoNode, EffectNode, DomBinding, UpdatePath, SignalTrace, EventBinding, SetterRef, FnSetterResolution, EventSummary, LoopInfo, LoopChildBinding, LoopSummary, WhyUpdateResult, WhyUpdateDep, WhyUpdateSource, FallbackExplanation, ComponentSummary } from './debug'
|
|
263
272
|
export type { WrapReason } from './ir-to-client-js/reactivity'
|
|
264
273
|
|
|
265
274
|
// HTML constants
|
|
@@ -1040,6 +1040,14 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
|
|
|
1040
1040
|
return `\${${transformExpr(node.expr, node.templateExpr)}}`
|
|
1041
1041
|
|
|
1042
1042
|
case 'conditional': {
|
|
1043
|
+
// A client-only conditional (auto-deferred brand read or manual
|
|
1044
|
+
// `/* @client */`) is owned by init's `insert()`, not the module-scope
|
|
1045
|
+
// template lambda. Match the SSR adapter: emit empty cond markers so
|
|
1046
|
+
// the client-render path (`createComponent`) produces the same DOM SSR
|
|
1047
|
+
// does, instead of evaluating an init-scope condition here (#1645).
|
|
1048
|
+
if (node.clientOnly && node.slotId) {
|
|
1049
|
+
return `<!--bf-cond-start:${node.slotId}--><!--bf-cond-end:${node.slotId}-->`
|
|
1050
|
+
}
|
|
1043
1051
|
const trueBranch = recurse(node.whenTrue)
|
|
1044
1052
|
const falseBranch = recurse(node.whenFalse)
|
|
1045
1053
|
const trueHtml = node.slotId ? addCondAttrToTemplate(trueBranch, node.slotId) : trueBranch
|
|
@@ -1418,6 +1426,15 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
|
|
|
1418
1426
|
}
|
|
1419
1427
|
|
|
1420
1428
|
case 'conditional': {
|
|
1429
|
+
// An auto-deferred conditional (e.g. `{form.field('x').error() && …}`)
|
|
1430
|
+
// reads per-instance init-scope state the module-scope template lambda
|
|
1431
|
+
// can't evaluate — re-deriving it here yields `undefined.field(...)` or
|
|
1432
|
+
// a throwaway re-inlined `createForm({...})`. Match the SSR adapter:
|
|
1433
|
+
// emit empty cond markers and let init's `insert()` populate the branch
|
|
1434
|
+
// at hydrate time via the reactive binding (#1645).
|
|
1435
|
+
if (node.clientOnly && node.slotId) {
|
|
1436
|
+
return `<!--bf-cond-start:${node.slotId}--><!--bf-cond-end:${node.slotId}-->`
|
|
1437
|
+
}
|
|
1421
1438
|
const trueBranch = recurse(node.whenTrue)
|
|
1422
1439
|
const falseBranch = recurse(node.whenFalse)
|
|
1423
1440
|
const trueHtml = node.slotId ? addCondAttrToTemplate(trueBranch, node.slotId) : trueBranch
|
package/src/jsx-to-ir.ts
CHANGED
|
@@ -1323,7 +1323,10 @@ function transformExpressionInner(
|
|
|
1323
1323
|
// IRLoop handles `isClientOnly` internally via `transformMapCall`; other
|
|
1324
1324
|
// shapes (IRElement / IRFragment / IRLoop / IRComponent from inline JSX
|
|
1325
1325
|
// helpers) are unchanged. Mirror that exactly here.
|
|
1326
|
-
|
|
1326
|
+
// A reactive brand-package condition (e.g. `form.field('x').error() &&
|
|
1327
|
+
// …`) can't be SSR-evaluated, so defer the whole conditional rather
|
|
1328
|
+
// than raising BF061 — same routing as a manual `/* @client */` (#1638).
|
|
1329
|
+
if ((isClientOnly || shouldAutoDeferReactiveBrand(expr, ctx)) && ir.type === 'conditional') {
|
|
1327
1330
|
ir.clientOnly = true
|
|
1328
1331
|
if (!ir.slotId) {
|
|
1329
1332
|
ir.slotId = generateSlotId(ctx)
|
|
@@ -3510,7 +3513,12 @@ function processAttributes(
|
|
|
3510
3513
|
// Downstream routing: collect-elements wires this into
|
|
3511
3514
|
// `reactiveAttrs` for elements; html-template strips it from
|
|
3512
3515
|
// the SSR template (and from `renderChild` for components).
|
|
3513
|
-
|
|
3516
|
+
//
|
|
3517
|
+
// Reactive brand-package reads (`value={form.field('x').value()}`)
|
|
3518
|
+
// are auto-deferred the same way: the SSR lambda can't evaluate the
|
|
3519
|
+
// init-scope form state, so defer instead of raising BF061 (#1638).
|
|
3520
|
+
if (hasLeadingClientDirective(attr.initializer.expression, ctx.sourceFile)
|
|
3521
|
+
|| shouldAutoDeferReactiveBrand(attr.initializer.expression, ctx)) {
|
|
3514
3522
|
clientOnly = true
|
|
3515
3523
|
}
|
|
3516
3524
|
}
|
|
@@ -4229,6 +4237,35 @@ function isReactiveExpression(expr: string, ctx: TransformContext, astNode?: ts.
|
|
|
4229
4237
|
return false
|
|
4230
4238
|
}
|
|
4231
4239
|
|
|
4240
|
+
/**
|
|
4241
|
+
* Decide whether a JSX expression should be auto-deferred to the client
|
|
4242
|
+
* (treated as if it carried `/* @client */`) because it reads reactive
|
|
4243
|
+
* brand-package state the SSR template lambda cannot evaluate (#1638).
|
|
4244
|
+
*
|
|
4245
|
+
* The motivating case is `@barefootjs/form`: `const form = createForm(...)`
|
|
4246
|
+
* is per-instance init-scope state, so `form.field('x').value()` /
|
|
4247
|
+
* `form.isSubmitting()` resolve to an init-local with no compiler-derivable
|
|
4248
|
+
* SSR value. Referencing them from a template position (element attribute,
|
|
4249
|
+
* conditional condition) otherwise raises BF061 and forces a manual
|
|
4250
|
+
* `/* @client */` on every binding.
|
|
4251
|
+
*
|
|
4252
|
+
* Gated tightly so it never demotes server-renderable reads:
|
|
4253
|
+
* - Requires the TypeChecker AND a `Reactive<T>` brand on the expression
|
|
4254
|
+
* (`containsReactiveExpression`), so plain values are untouched.
|
|
4255
|
+
* - Excludes native `createSignal` / `createMemo` getters (and their
|
|
4256
|
+
* chained-const aliases): they carry the same brand but DO have a
|
|
4257
|
+
* derivable initial value, so they must keep rendering server-side.
|
|
4258
|
+
*/
|
|
4259
|
+
function shouldAutoDeferReactiveBrand(expr: ts.Expression, ctx: TransformContext): boolean {
|
|
4260
|
+
const checker = ctx.analyzer.checker
|
|
4261
|
+
if (!checker) return false
|
|
4262
|
+
if (!containsReactiveExpression(expr, checker)) return false
|
|
4263
|
+
// Native signals/memos (incl. chained-const aliases) are SSR-derivable —
|
|
4264
|
+
// leave them to the normal template path so their initial value renders.
|
|
4265
|
+
if (isSignalOrMemoReference(ctx.getJS(expr), ctx)) return false
|
|
4266
|
+
return true
|
|
4267
|
+
}
|
|
4268
|
+
|
|
4232
4269
|
/**
|
|
4233
4270
|
* Regex-based signal/memo detection.
|
|
4234
4271
|
* Complements TypeChecker for cases where imported types can't be resolved.
|