@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/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
- via?: string
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
- function buildLocalFunctionSetterMap(
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, string[]> {
457
+ ): Map<string, FnSetterResolution[]> {
398
458
  const setterPatterns = [...setterToSignal.keys()].map(s => ({ name: s, re: makeIdCallRegex(s) }))
399
- const result = new Map<string, string[]>()
400
- for (const fn of meta.localFunctions) {
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(fn.body)) setters.push(name)
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
- if (setters.length > 0) result.set(fn.name, setters)
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, 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, 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, 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, setters] of fnSetters) {
626
+ for (const [fnName, resolutions] of fnSetters) {
516
627
  if (trimmed === fnName || makeIdCallRegex(fnName).test(handler)) {
517
- for (const setter of setters) {
518
- if (!seen.has(setter)) {
519
- refs.push({ setter, signal: setterToSignal.get(setter) ?? null, via: fnName })
520
- seen.add(setter)
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
- if (isClientOnly && ir.type === 'conditional') {
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
- if (hasLeadingClientDirective(attr.initializer.expression, ctx.sourceFile)) {
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.