@barefootjs/jsx 0.1.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/debug.d.ts +66 -1
  2. package/dist/debug.d.ts.map +1 -1
  3. package/dist/html-constants.d.ts +4 -9
  4. package/dist/html-constants.d.ts.map +1 -1
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +8628 -8071
  8. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/html-template.d.ts +15 -0
  11. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/types.d.ts +5 -0
  13. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/utils.d.ts +2 -8
  15. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  16. package/dist/prop-rewrite.d.ts.map +1 -1
  17. package/dist/types.d.ts +20 -0
  18. package/dist/types.d.ts.map +1 -1
  19. package/package.json +3 -3
  20. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +135 -0
  21. package/src/__tests__/boolean-attributes.test.ts +2 -1
  22. package/src/__tests__/conditional-branch-reactive-text.test.ts +108 -0
  23. package/src/__tests__/debug.test.ts +422 -9
  24. package/src/__tests__/doc-examples.test.ts +7 -0
  25. package/src/__tests__/ir-provider.test.ts +98 -0
  26. package/src/__tests__/rewrite-destructured-props.test.ts +73 -0
  27. package/src/debug.ts +637 -32
  28. package/src/html-constants.ts +4 -27
  29. package/src/index.ts +6 -1
  30. package/src/ir-to-client-js/collect-elements.ts +3 -0
  31. package/src/ir-to-client-js/emit-reactive.ts +5 -5
  32. package/src/ir-to-client-js/html-template.ts +97 -11
  33. package/src/ir-to-client-js/types.ts +6 -0
  34. package/src/ir-to-client-js/utils.ts +4 -65
  35. package/src/jsx-to-ir.ts +92 -17
  36. package/src/prop-rewrite.ts +6 -2
  37. package/src/types.ts +21 -0
package/src/debug.ts CHANGED
@@ -12,10 +12,14 @@ import type {
12
12
  IRConditional,
13
13
  IRElement,
14
14
  IRLoop,
15
+ IRComponent,
16
+ IRText,
17
+ IRMetadata,
15
18
  AttrValue,
16
19
  SignalInfo,
17
20
  MemoInfo,
18
21
  EffectInfo,
22
+ SourceLocation,
19
23
  } from './types'
20
24
  import { analyzeComponent, listComponentFunctions } from './analyzer'
21
25
  import { jsxToIR } from './jsx-to-ir'
@@ -93,6 +97,8 @@ export interface DomBinding {
93
97
  * bindings; omitted for event handlers (not subject to the wrap gate).
94
98
  */
95
99
  wrapReason?: WrapReason
100
+ loc?: SourceLocation
101
+ jsxPreview?: string
96
102
  }
97
103
 
98
104
  export interface ComponentGraph {
@@ -118,6 +124,64 @@ export interface UpdatePathEntry {
118
124
  children: UpdatePathEntry[]
119
125
  }
120
126
 
127
+ // -- Event analysis types -----------------------------------------------------
128
+
129
+ export interface EventBinding {
130
+ elementTag: string
131
+ elementContext: string
132
+ eventName: string
133
+ handler: string
134
+ setterCalls: SetterRef[]
135
+ loc: SourceLocation
136
+ isComponentProp: boolean
137
+ }
138
+
139
+ export interface SetterRef {
140
+ setter: string
141
+ signal: string | null
142
+ via?: string
143
+ }
144
+
145
+ export interface EventSummary {
146
+ componentName: string
147
+ sourceFile: string
148
+ events: EventBinding[]
149
+ graph: ComponentGraph
150
+ }
151
+
152
+ // -- Loop analysis types ------------------------------------------------------
153
+
154
+ export interface LoopInfo {
155
+ array: string
156
+ param: string
157
+ index: string | null
158
+ key: string | null
159
+ method: 'map' | 'flatMap'
160
+ bindings: LoopChildBinding[]
161
+ loc: SourceLocation
162
+ }
163
+
164
+ export interface LoopChildBinding {
165
+ elementContext: string
166
+ kind: 'attribute' | 'text' | 'event'
167
+ name: string
168
+ deps: string[]
169
+ loc?: SourceLocation
170
+ }
171
+
172
+ export interface LoopSummary {
173
+ componentName: string
174
+ sourceFile: string
175
+ loops: LoopInfo[]
176
+ }
177
+
178
+ // -- Component analysis (shared IR + graph) -----------------------------------
179
+
180
+ export interface ComponentAnalysis {
181
+ graph: ComponentGraph
182
+ ir: ComponentIR
183
+ }
184
+
121
185
  // =============================================================================
122
186
  // Analysis: Build Component Graph
123
187
  // =============================================================================
@@ -262,6 +326,516 @@ export function buildGraphFromIR(ir: ComponentIR): ComponentGraph {
262
326
  }
263
327
  }
264
328
 
329
+ /**
330
+ * Build both the ComponentIR and the reactive dependency graph in one pass.
331
+ * Callers that need the raw IR tree (events, loops, why-update) use this
332
+ * instead of `buildComponentGraph` to avoid a redundant analysis round.
333
+ */
334
+ export function buildComponentAnalysis(source: string, filePath: string, componentName?: string): ComponentAnalysis {
335
+ const ctx = analyzeComponent(source, filePath, componentName)
336
+ const emptyIR: ComponentIR = {
337
+ version: '0.1',
338
+ metadata: buildMetadata(ctx),
339
+ root: { type: 'fragment', children: [], loc: { file: filePath, start: { line: 1, column: 0 }, end: { line: 1, column: 0 } } },
340
+ errors: [],
341
+ }
342
+
343
+ if (!ctx.jsxReturn) {
344
+ return { graph: buildGraphFromIR(emptyIR), ir: emptyIR }
345
+ }
346
+
347
+ const root = jsxToIR(ctx)
348
+ if (!root) {
349
+ return { graph: buildGraphFromIR(emptyIR), ir: emptyIR }
350
+ }
351
+
352
+ const ir: ComponentIR = { version: '0.1', metadata: buildMetadata(ctx), root, errors: [] }
353
+ return { graph: buildGraphFromIR(ir), ir }
354
+ }
355
+
356
+ // =============================================================================
357
+ // Analysis: Event Bindings
358
+ // =============================================================================
359
+
360
+ /**
361
+ * Build a complete event summary for a component, including setter resolution
362
+ * and downstream update paths.
363
+ */
364
+ export function buildEventSummary(source: string, filePath: string, componentName?: string): EventSummary {
365
+ const { graph, ir } = buildComponentAnalysis(source, filePath, componentName)
366
+ const setterToSignal = new Map<string, string>()
367
+ for (const s of ir.metadata.signals) {
368
+ if (s.setter) setterToSignal.set(s.setter, s.getter)
369
+ }
370
+
371
+ const fnSetters = buildLocalFunctionSetterMap(ir.metadata, setterToSignal)
372
+ const events = collectEventBindings(ir.root, setterToSignal, fnSetters)
373
+
374
+ return {
375
+ componentName: graph.componentName,
376
+ sourceFile: graph.sourceFile,
377
+ events,
378
+ graph,
379
+ }
380
+ }
381
+
382
+ function escapeForIdBoundary(name: string): string {
383
+ return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
384
+ }
385
+
386
+ function makeIdCallRegex(name: string): RegExp {
387
+ return new RegExp(`(?:^|[^\\w$])${escapeForIdBoundary(name)}\\s*\\(`)
388
+ }
389
+
390
+ function makeIdRefRegex(name: string): RegExp {
391
+ return new RegExp(`(?:^|[^\\w$])${escapeForIdBoundary(name)}(?:[^\\w$]|$)`)
392
+ }
393
+
394
+ function buildLocalFunctionSetterMap(
395
+ meta: IRMetadata,
396
+ setterToSignal: Map<string, string>,
397
+ ): Map<string, string[]> {
398
+ 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) {
401
+ const setters: string[] = []
402
+ for (const { name, re } of setterPatterns) {
403
+ if (re.test(fn.body)) setters.push(name)
404
+ }
405
+ if (setters.length > 0) result.set(fn.name, setters)
406
+ }
407
+ return result
408
+ }
409
+
410
+ function collectEventBindings(
411
+ node: IRNode,
412
+ setterToSignal: Map<string, string>,
413
+ fnSetters: Map<string, string[]>,
414
+ ): EventBinding[] {
415
+ const events: EventBinding[] = []
416
+ walkForEvents(node, events, setterToSignal, fnSetters)
417
+ return events
418
+ }
419
+
420
+ function walkForEvents(
421
+ node: IRNode,
422
+ events: EventBinding[],
423
+ setterToSignal: Map<string, string>,
424
+ fnSetters: Map<string, string[]>,
425
+ ): void {
426
+ switch (node.type) {
427
+ case 'element': {
428
+ for (const event of node.events) {
429
+ events.push({
430
+ elementTag: node.tag,
431
+ elementContext: describeElement(node),
432
+ eventName: event.originalAttr ?? `on${event.name[0].toUpperCase()}${event.name.slice(1)}`,
433
+ handler: event.handler,
434
+ setterCalls: resolveSetters(event.handler, setterToSignal, fnSetters),
435
+ loc: event.loc,
436
+ isComponentProp: false,
437
+ })
438
+ }
439
+ for (const child of node.children) {
440
+ walkForEvents(child, events, setterToSignal, fnSetters)
441
+ }
442
+ break
443
+ }
444
+ case 'component': {
445
+ for (const prop of node.props) {
446
+ if (!/^on[A-Z]/.test(prop.name)) continue
447
+ const handler = prop.value.kind === 'expression' ? prop.value.expr : null
448
+ if (!handler) continue
449
+ events.push({
450
+ elementTag: node.name,
451
+ elementContext: describeComponent(node),
452
+ eventName: prop.name,
453
+ handler,
454
+ setterCalls: resolveSetters(handler, setterToSignal, fnSetters),
455
+ loc: prop.loc,
456
+ isComponentProp: true,
457
+ })
458
+ }
459
+ for (const child of node.children) {
460
+ walkForEvents(child, events, setterToSignal, fnSetters)
461
+ }
462
+ break
463
+ }
464
+ case 'fragment':
465
+ case 'provider': {
466
+ for (const child of node.children) {
467
+ walkForEvents(child, events, setterToSignal, fnSetters)
468
+ }
469
+ break
470
+ }
471
+ case 'conditional': {
472
+ walkForEvents(node.whenTrue, events, setterToSignal, fnSetters)
473
+ walkForEvents(node.whenFalse, events, setterToSignal, fnSetters)
474
+ break
475
+ }
476
+ case 'loop': {
477
+ for (const child of node.children) {
478
+ walkForEvents(child, events, setterToSignal, fnSetters)
479
+ }
480
+ break
481
+ }
482
+ case 'if-statement': {
483
+ walkForEvents(node.consequent, events, setterToSignal, fnSetters)
484
+ if (node.alternate) walkForEvents(node.alternate, events, setterToSignal, fnSetters)
485
+ break
486
+ }
487
+ case 'async': {
488
+ walkForEvents(node.fallback, events, setterToSignal, fnSetters)
489
+ for (const child of node.children) {
490
+ walkForEvents(child, events, setterToSignal, fnSetters)
491
+ }
492
+ break
493
+ }
494
+ }
495
+ }
496
+
497
+ function resolveSetters(
498
+ handler: string,
499
+ setterToSignal: Map<string, string>,
500
+ fnSetters: Map<string, string[]>,
501
+ ): SetterRef[] {
502
+ const refs: SetterRef[] = []
503
+ const seen = new Set<string>()
504
+ const trimmed = handler.trim()
505
+
506
+ for (const [setter, signal] of setterToSignal) {
507
+ if (trimmed === setter || makeIdCallRegex(setter).test(handler)) {
508
+ if (!seen.has(setter)) {
509
+ refs.push({ setter, signal })
510
+ seen.add(setter)
511
+ }
512
+ }
513
+ }
514
+
515
+ for (const [fnName, setters] of fnSetters) {
516
+ 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)
521
+ }
522
+ }
523
+ }
524
+ }
525
+
526
+ return refs
527
+ }
528
+
529
+ function describeElement(node: IRElement): string {
530
+ for (const attr of node.attrs) {
531
+ if (['type', 'name', 'placeholder', 'id'].includes(attr.name) && attr.value.kind === 'literal') {
532
+ return `${node.tag} ${attr.value.value}`
533
+ }
534
+ }
535
+ const textChild = node.children.find((c): c is IRText => c.type === 'text')
536
+ if (textChild && textChild.value.trim()) {
537
+ return `${textChild.value.trim()} ${node.tag}`
538
+ }
539
+ return node.tag
540
+ }
541
+
542
+ function describeComponent(node: IRComponent): string {
543
+ const textChild = node.children.find((c): c is IRText => c.type === 'text')
544
+ if (textChild && textChild.value.trim()) {
545
+ return `${textChild.value.trim()} ${node.name}`
546
+ }
547
+ return node.name
548
+ }
549
+
550
+ /**
551
+ * Format an event summary as a human-readable string for `bf debug events`.
552
+ * Uses the graph to trace downstream updates for each setter.
553
+ */
554
+ export function formatEventSummary(summary: EventSummary, graph: ComponentGraph): string {
555
+ const lines: string[] = []
556
+ lines.push(`${summary.componentName} — ${summary.events.length} event handler(s)`)
557
+
558
+ if (summary.events.length === 0) return lines.join('\n')
559
+
560
+ for (const event of summary.events) {
561
+ lines.push('')
562
+ lines.push(` ${event.elementContext}`)
563
+
564
+ const setterParts = event.setterCalls.map(s => {
565
+ const chain = s.via ? `${s.via} -> ${s.setter}` : s.setter
566
+ return chain
567
+ })
568
+
569
+ const setterStr = setterParts.length > 0 ? setterParts.join(', ') : event.handler
570
+ lines.push(` ${event.eventName} -> ${setterStr}`)
571
+
572
+ const updatedSignals = new Set<string>()
573
+ for (const sc of event.setterCalls) {
574
+ if (sc.signal) updatedSignals.add(sc.signal)
575
+ }
576
+
577
+ if (updatedSignals.size > 0) {
578
+ const targets: string[] = []
579
+ for (const sig of updatedSignals) {
580
+ const path = traceUpdatePath(graph, sig)
581
+ if (path && path.dependents.length > 0) {
582
+ const downstream = flattenUpdateTargets(path.dependents)
583
+ targets.push(`${sig} -> ${downstream.join(', ')}`)
584
+ }
585
+ }
586
+ if (targets.length > 0) {
587
+ lines.push(` updates: ${targets.join('; ')}`)
588
+ }
589
+ }
590
+
591
+ const loc = event.loc
592
+ if (loc.file) {
593
+ const locFile = loc.file.split('/').pop() ?? loc.file
594
+ lines.push(` at ${locFile}:${loc.start.line}`)
595
+ }
596
+ }
597
+
598
+ return lines.join('\n')
599
+ }
600
+
601
+ function flattenUpdateTargets(entries: UpdatePathEntry[]): string[] {
602
+ const targets: string[] = []
603
+ for (const entry of entries) {
604
+ if (entry.kind === 'dom') {
605
+ targets.push(entry.label)
606
+ } else if (entry.kind === 'memo') {
607
+ targets.push(entry.name)
608
+ if (entry.children.length > 0) {
609
+ targets.push(...flattenUpdateTargets(entry.children))
610
+ }
611
+ } else if (entry.kind === 'effect') {
612
+ targets.push(`effect ${entry.name}`)
613
+ }
614
+ }
615
+ return targets
616
+ }
617
+
618
+ // =============================================================================
619
+ // Analysis: Loop Bindings
620
+ // =============================================================================
621
+
622
+ interface PrecompiledLoopPatterns {
623
+ signalCallPatterns: Array<{ name: string; re: RegExp }>
624
+ memoCallPatterns: Array<{ name: string; re: RegExp }>
625
+ paramRefPatterns: Array<{ name: string; re: RegExp }>
626
+ }
627
+
628
+ export function buildLoopSummary(source: string, filePath: string, componentName?: string): LoopSummary {
629
+ const { graph, ir } = buildComponentAnalysis(source, filePath, componentName)
630
+ const signalGetters = new Set(ir.metadata.signals.map(s => s.getter))
631
+ const memoNames = new Set(ir.metadata.memos.map(m => m.name))
632
+ const loops: LoopInfo[] = []
633
+ collectLoops(ir.root, loops, signalGetters, memoNames)
634
+ return { componentName: graph.componentName, sourceFile: graph.sourceFile, loops }
635
+ }
636
+
637
+ function collectLoops(
638
+ node: IRNode,
639
+ loops: LoopInfo[],
640
+ signalGetters: Set<string>,
641
+ memoNames: Set<string>,
642
+ ): void {
643
+ switch (node.type) {
644
+ case 'loop': {
645
+ const bindings: LoopChildBinding[] = []
646
+ const paramNames = extractLoopParamNames(node.param, node)
647
+ if (node.index) paramNames.push(node.index)
648
+ const patterns: PrecompiledLoopPatterns = {
649
+ signalCallPatterns: [...signalGetters].map(g => ({ name: g, re: makeIdCallRegex(g) })),
650
+ memoCallPatterns: [...memoNames].map(m => ({ name: m, re: makeIdCallRegex(m) })),
651
+ paramRefPatterns: paramNames.map(n => ({ name: n, re: makeIdRefRegex(n) })),
652
+ }
653
+ collectLoopChildBindings(node.children, bindings, patterns)
654
+ loops.push({
655
+ array: node.array,
656
+ param: node.param,
657
+ index: node.index ?? null,
658
+ key: node.key ?? null,
659
+ method: node.method === 'flatMap' ? 'flatMap' : 'map',
660
+ bindings,
661
+ loc: node.loc,
662
+ })
663
+ for (const child of node.children) collectLoops(child, loops, signalGetters, memoNames)
664
+ break
665
+ }
666
+ case 'element': {
667
+ for (const child of node.children) collectLoops(child, loops, signalGetters, memoNames)
668
+ break
669
+ }
670
+ case 'component': {
671
+ for (const child of node.children) collectLoops(child, loops, signalGetters, memoNames)
672
+ break
673
+ }
674
+ case 'fragment':
675
+ case 'provider': {
676
+ for (const child of node.children) collectLoops(child, loops, signalGetters, memoNames)
677
+ break
678
+ }
679
+ case 'conditional': {
680
+ collectLoops(node.whenTrue, loops, signalGetters, memoNames)
681
+ collectLoops(node.whenFalse, loops, signalGetters, memoNames)
682
+ break
683
+ }
684
+ case 'if-statement': {
685
+ collectLoops(node.consequent, loops, signalGetters, memoNames)
686
+ if (node.alternate) collectLoops(node.alternate, loops, signalGetters, memoNames)
687
+ break
688
+ }
689
+ case 'async': {
690
+ collectLoops(node.fallback, loops, signalGetters, memoNames)
691
+ for (const child of node.children) collectLoops(child, loops, signalGetters, memoNames)
692
+ break
693
+ }
694
+ }
695
+ }
696
+
697
+ function collectLoopChildBindings(
698
+ children: IRNode[],
699
+ bindings: LoopChildBinding[],
700
+ patterns: PrecompiledLoopPatterns,
701
+ parentTag?: string,
702
+ ): void {
703
+ for (const child of children) {
704
+ switch (child.type) {
705
+ case 'element': {
706
+ const ctx = child.tag
707
+ for (const attr of child.attrs) {
708
+ if (attr.name === 'key' || attr.name === '...' || attr.name.startsWith('...')) continue
709
+ if (attr.value.kind !== 'expression' && attr.value.kind !== 'template' && attr.value.kind !== 'spread') continue
710
+ const expr = attrValueToString(attr.value)
711
+ if (!expr) continue
712
+ const deps = collectLoopDepsPrecompiled(expr, patterns)
713
+ if (deps.length > 0) {
714
+ bindings.push({ elementContext: ctx, kind: 'attribute', name: attr.name, deps, loc: attr.loc })
715
+ }
716
+ }
717
+ for (const event of child.events) {
718
+ const deps = collectLoopDepsPrecompiled(event.handler, patterns)
719
+ bindings.push({
720
+ elementContext: ctx,
721
+ kind: 'event',
722
+ name: event.originalAttr ?? `on${event.name[0].toUpperCase()}${event.name.slice(1)}`,
723
+ deps,
724
+ loc: event.loc,
725
+ })
726
+ }
727
+ collectLoopChildBindings(child.children, bindings, patterns, ctx)
728
+ break
729
+ }
730
+ case 'expression': {
731
+ if (child.slotId) {
732
+ const deps = collectLoopDepsPrecompiled(child.expr, patterns)
733
+ if (deps.length > 0) {
734
+ bindings.push({ elementContext: parentTag ?? 'text', kind: 'text', name: child.expr, deps, loc: child.loc })
735
+ }
736
+ }
737
+ break
738
+ }
739
+ case 'component': {
740
+ const ctx = child.name
741
+ for (const prop of child.props) {
742
+ if (prop.name === '...' || prop.name.startsWith('...')) continue
743
+ if (prop.value.kind !== 'expression' && prop.value.kind !== 'template' && prop.value.kind !== 'spread') continue
744
+ const propValue = attrValueToString(prop.value) ?? ''
745
+ if (!propValue) continue
746
+ const deps = collectLoopDepsPrecompiled(propValue, patterns)
747
+ if (deps.length > 0) {
748
+ const isEvent = /^on[A-Z]/.test(prop.name)
749
+ bindings.push({ elementContext: ctx, kind: isEvent ? 'event' : 'attribute', name: prop.name, deps, loc: prop.loc })
750
+ }
751
+ }
752
+ for (const c of child.children) {
753
+ collectLoopChildBindings([c], bindings, patterns, parentTag)
754
+ }
755
+ break
756
+ }
757
+ case 'conditional': {
758
+ collectLoopChildBindings([child.whenTrue], bindings, patterns, parentTag)
759
+ collectLoopChildBindings([child.whenFalse], bindings, patterns, parentTag)
760
+ break
761
+ }
762
+ case 'fragment':
763
+ case 'provider': {
764
+ collectLoopChildBindings(child.children, bindings, patterns, parentTag)
765
+ break
766
+ }
767
+ case 'loop': {
768
+ break
769
+ }
770
+ case 'if-statement': {
771
+ collectLoopChildBindings([child.consequent], bindings, patterns, parentTag)
772
+ if (child.alternate) collectLoopChildBindings([child.alternate], bindings, patterns, parentTag)
773
+ break
774
+ }
775
+ case 'async': {
776
+ collectLoopChildBindings([child.fallback], bindings, patterns, parentTag)
777
+ collectLoopChildBindings(child.children, bindings, patterns, parentTag)
778
+ break
779
+ }
780
+ }
781
+ }
782
+ }
783
+
784
+ function extractLoopParamNames(loopParam: string, node: IRLoop): string[] {
785
+ if (node.paramBindings && node.paramBindings.length > 0) {
786
+ return node.paramBindings.map(b => b.name)
787
+ }
788
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(loopParam)) {
789
+ return [loopParam]
790
+ }
791
+ return []
792
+ }
793
+
794
+ function collectLoopDepsPrecompiled(
795
+ expr: string,
796
+ patterns: PrecompiledLoopPatterns,
797
+ ): string[] {
798
+ const deps: string[] = []
799
+ for (const { name, re } of patterns.signalCallPatterns) {
800
+ if (re.test(expr)) deps.push(name)
801
+ }
802
+ for (const { name, re } of patterns.memoCallPatterns) {
803
+ if (re.test(expr)) deps.push(name)
804
+ }
805
+ for (const { name, re } of patterns.paramRefPatterns) {
806
+ if (re.test(expr)) deps.push(name)
807
+ }
808
+ return deps
809
+ }
810
+
811
+ export function formatLoopSummary(summary: LoopSummary): string {
812
+ const lines: string[] = []
813
+ lines.push(`${summary.componentName} — ${summary.loops.length} loop(s)`)
814
+
815
+ for (const loop of summary.loops) {
816
+ lines.push('')
817
+ const params = loop.index ? `${loop.param}, ${loop.index}` : loop.param
818
+ lines.push(` ${loop.array}.${loop.method}(${params})`)
819
+ if (loop.key) lines.push(` key: ${loop.key}`)
820
+
821
+ for (const b of loop.bindings) {
822
+ const depStr = b.deps.length > 0 ? b.deps.join(', ') : '(no deps)'
823
+ if (b.kind === 'event') {
824
+ lines.push(` ${b.elementContext} ${b.name} -> ${depStr}`)
825
+ } else if (b.kind === 'attribute') {
826
+ lines.push(` ${b.elementContext} ${b.name} <- ${depStr}`)
827
+ } else {
828
+ lines.push(` ${b.elementContext} <- ${depStr}`)
829
+ }
830
+ }
831
+
832
+ const locFile = loop.loc.file.split('/').pop() ?? loop.loc.file
833
+ lines.push(` at ${locFile}:${loop.loc.start.line}`)
834
+ }
835
+
836
+ return lines.join('\n')
837
+ }
838
+
265
839
  // =============================================================================
266
840
  // Analysis: Update Propagation Path (why-update)
267
841
  // =============================================================================
@@ -365,6 +939,7 @@ export function formatComponentGraph(graph: ComponentGraph): string {
365
939
  // For attribute bindings use the attr name; for others use slotId
366
940
  const id = d.type === 'attribute' ? `"${d.label}"` : `"${d.slotId}"`
367
941
  const marker = d.classification === 'fallback' ? '~ ' : ' '
942
+ const locSuffix = formatBindingLoc(d)
368
943
  // No tracked deps ⇒ drop the arrow entirely instead of emitting
369
944
  // a dangling `<- ` (trailing space). Fallback-wrapped attribute
370
945
  // handlers like `<Button onClick={() => setCount(0)}>` legitimately
@@ -372,12 +947,24 @@ export function formatComponentGraph(graph: ComponentGraph): string {
372
947
  // it explicitly so the reader doesn't wonder if the analyzer
373
948
  // dropped data.
374
949
  if (d.deps.length === 0) {
375
- lines.push(` ${marker}${d.type} ${id} (no tracked deps)`)
950
+ if (d.jsxPreview) {
951
+ lines.push(` ${marker}${d.jsxPreview} (no tracked deps)${locSuffix}`)
952
+ } else {
953
+ lines.push(` ${marker}${d.type} ${id} (no tracked deps)${locSuffix}`)
954
+ }
376
955
  continue
377
956
  }
378
- const arrow = d.type === 'event' ? ' ->' : ' <-'
379
957
  const depStr = d.deps.join(', ')
380
- lines.push(` ${marker}${d.type} ${id}${arrow} ${depStr}`)
958
+ if (d.jsxPreview) {
959
+ if (d.type === 'event') {
960
+ lines.push(` ${marker}${d.jsxPreview} -> ${depStr}${locSuffix}`)
961
+ } else {
962
+ lines.push(` ${marker}${depStr} -> ${d.jsxPreview}${locSuffix}`)
963
+ }
964
+ } else {
965
+ const arrow = d.type === 'event' ? ' ->' : ' <-'
966
+ lines.push(` ${marker}${d.type} ${id}${arrow} ${depStr}${locSuffix}`)
967
+ }
381
968
  }
382
969
  }
383
970
 
@@ -412,6 +999,12 @@ export function formatUpdatePath(path: UpdatePath): string {
412
999
  return lines.join('\n')
413
1000
  }
414
1001
 
1002
+ function formatBindingLoc(d: DomBinding): string {
1003
+ if (!d.loc) return ''
1004
+ const file = d.loc.file.split('/').pop() ?? d.loc.file
1005
+ return ` at ${file}:${d.loc.start.line}`
1006
+ }
1007
+
415
1008
  function formatEntry(entry: UpdatePathEntry, lines: string[], indent: string): void {
416
1009
  const arrow = entry.kind === 'dom' ? '->' : '<-'
417
1010
  lines.push(`${indent}${arrow} ${entry.label}`)
@@ -452,6 +1045,8 @@ export function graphToJSON(graph: ComponentGraph): object {
452
1045
  type: d.type,
453
1046
  classification: d.classification,
454
1047
  ...(d.expression !== undefined && { expression: d.expression }),
1048
+ ...(d.loc && { loc: { file: d.loc.file, line: d.loc.start.line } }),
1049
+ ...(d.jsxPreview && { jsxPreview: d.jsxPreview }),
455
1050
  })),
456
1051
  }
457
1052
  }
@@ -578,6 +1173,7 @@ function collectDomBindings(
578
1173
  bindings: DomBinding[],
579
1174
  signalGetters: Set<string>,
580
1175
  memoNames: Set<string>,
1176
+ parentTag?: string,
581
1177
  ): void {
582
1178
  switch (node.type) {
583
1179
  case 'element': {
@@ -604,6 +1200,10 @@ function collectDomBindings(
604
1200
  classification: isReactive ? 'reactive' : 'fallback',
605
1201
  expression: expr,
606
1202
  wrapReason,
1203
+ loc: attr.loc,
1204
+ jsxPreview: attr.value.kind === 'spread'
1205
+ ? `<${node.tag} {...${truncateExpr(expr)}}>`
1206
+ : `<${node.tag} ${attr.name}={${truncateExpr(expr)}}>`,
607
1207
  })
608
1208
  }
609
1209
  }
@@ -617,11 +1217,13 @@ function collectDomBindings(
617
1217
  deps: extractSetterRefs(event.handler, signalGetters),
618
1218
  type: 'event',
619
1219
  classification: 'reactive',
1220
+ loc: event.loc,
1221
+ jsxPreview: `<${node.tag} ${event.originalAttr ?? `on${event.name[0].toUpperCase()}${event.name.slice(1)}`}={...}>`,
620
1222
  })
621
1223
  }
622
- // Recurse
1224
+ // Recurse — pass element tag as parent context for text bindings
623
1225
  for (const child of node.children) {
624
- collectDomBindings(child, bindings, signalGetters, memoNames)
1226
+ collectDomBindings(child, bindings, signalGetters, memoNames, node.tag)
625
1227
  }
626
1228
  break
627
1229
  }
@@ -631,6 +1233,9 @@ function collectDomBindings(
631
1233
  const decision = decideWrapFromAstFlags(node)
632
1234
  if (decision.wrap && node.slotId) {
633
1235
  const deps = extractReactiveDeps(node.expr, signalGetters, memoNames)
1236
+ const preview = parentTag
1237
+ ? `<${parentTag}>{${truncateExpr(node.expr)}}</${parentTag}>`
1238
+ : `{${truncateExpr(node.expr)}}`
634
1239
  bindings.push({
635
1240
  kind: 'dom',
636
1241
  label: `text "${node.slotId}"`,
@@ -640,6 +1245,8 @@ function collectDomBindings(
640
1245
  classification: decision.reason === 'proven-reactive' ? 'reactive' : 'fallback',
641
1246
  expression: node.expr,
642
1247
  wrapReason: decision.reason,
1248
+ loc: node.loc,
1249
+ jsxPreview: preview,
643
1250
  })
644
1251
  }
645
1252
  break
@@ -657,10 +1264,12 @@ function collectDomBindings(
657
1264
  classification: decision.reason === 'proven-reactive' ? 'reactive' : 'fallback',
658
1265
  expression: node.condition,
659
1266
  wrapReason: decision.reason,
1267
+ loc: node.loc,
1268
+ jsxPreview: `{${truncateExpr(node.condition)} ? ... : ...}`,
660
1269
  })
661
1270
  }
662
- collectDomBindings(node.whenTrue, bindings, signalGetters, memoNames)
663
- collectDomBindings(node.whenFalse, bindings, signalGetters, memoNames)
1271
+ collectDomBindings(node.whenTrue, bindings, signalGetters, memoNames, parentTag)
1272
+ collectDomBindings(node.whenFalse, bindings, signalGetters, memoNames, parentTag)
664
1273
  break
665
1274
  }
666
1275
  case 'loop': {
@@ -694,37 +1303,20 @@ function collectDomBindings(
694
1303
  classification: isReactive ? 'reactive' : 'fallback',
695
1304
  expression: node.array,
696
1305
  wrapReason,
1306
+ loc: node.loc,
1307
+ jsxPreview: `{${truncateExpr(node.array)}.${node.method === 'flatMap' ? 'flatMap' : 'map'}(${node.param} => ...)}`,
697
1308
  })
698
1309
  }
699
1310
  }
700
1311
  for (const child of node.children) {
701
- collectDomBindings(child, bindings, signalGetters, memoNames)
1312
+ collectDomBindings(child, bindings, signalGetters, memoNames, parentTag)
702
1313
  }
703
1314
  break
704
1315
  }
705
1316
  case 'component': {
706
1317
  // Child-component prop bindings (#942 DRY-consolidated in #952).
707
- // Emitter gate in collect-elements.ts:442 is
708
- // `hasPropsRef || needsEffectWrapper(expandedValue) || prop.callsReactiveGetters || prop.hasFunctionCalls`.
709
- // `deps.length > 0` + `prop.value.includes('props.')` together
710
- // approximate the first two branches (statically-proven reactive);
711
- // the AST flags cover the fallback case.
712
- //
713
- // Before #944 this case fell through silently, so fallback-wrapped
714
- // child props like `<Card title={formatTitle(page)} />` — the exact
715
- // motivating example for #942 — never reached `why-wrap`'s output.
716
- // The switch now iterates props and recurses into children, mirroring
717
- // the `case 'element'` structure.
718
- //
719
- // Label uses `"ComponentName.propName"` so output distinguishes
720
- // native-element attributes from child-component props without
721
- // introducing a new `DomBinding.type` variant (keeps the existing
722
- // type union stable for consumers).
723
1318
  for (const prop of node.props) {
724
1319
  if (prop.name === '...' || prop.name.startsWith('...')) continue
725
- // Only `expression` / `template` / `spread` carry a runtime expression
726
- // worth probing for reactive deps. `jsx-children` is handled via child
727
- // traversal below; literal / boolean produce no reactive bindings.
728
1320
  if (prop.value.kind !== 'expression' && prop.value.kind !== 'template' && prop.value.kind !== 'spread') continue
729
1321
  const propValue = attrValueToString(prop.value) ?? ''
730
1322
  if (!propValue) continue
@@ -742,31 +1334,40 @@ function collectDomBindings(
742
1334
  classification: isReactive ? 'reactive' : 'fallback',
743
1335
  expression: propValue,
744
1336
  wrapReason,
1337
+ loc: prop.loc,
1338
+ jsxPreview: prop.value.kind === 'spread'
1339
+ ? `<${node.name} {...${truncateExpr(propValue)}}>`
1340
+ : `<${node.name} ${prop.name}={${truncateExpr(propValue)}}>`,
745
1341
  })
746
1342
  }
747
1343
  }
748
1344
  for (const child of node.children) {
749
- collectDomBindings(child, bindings, signalGetters, memoNames)
1345
+ collectDomBindings(child, bindings, signalGetters, memoNames, parentTag)
750
1346
  }
751
1347
  break
752
1348
  }
753
1349
  case 'fragment':
754
1350
  case 'provider': {
755
1351
  for (const child of node.children) {
756
- collectDomBindings(child, bindings, signalGetters, memoNames)
1352
+ collectDomBindings(child, bindings, signalGetters, memoNames, parentTag)
757
1353
  }
758
1354
  break
759
1355
  }
760
1356
  case 'if-statement': {
761
- collectDomBindings(node.consequent, bindings, signalGetters, memoNames)
1357
+ collectDomBindings(node.consequent, bindings, signalGetters, memoNames, parentTag)
762
1358
  if (node.alternate) {
763
- collectDomBindings(node.alternate, bindings, signalGetters, memoNames)
1359
+ collectDomBindings(node.alternate, bindings, signalGetters, memoNames, parentTag)
764
1360
  }
765
1361
  break
766
1362
  }
767
1363
  }
768
1364
  }
769
1365
 
1366
+ function truncateExpr(expr: string, max: number = 40): string {
1367
+ const s = expr.replace(/\s+/g, ' ').trim()
1368
+ return s.length > max ? s.slice(0, max - 1) + '…' : s
1369
+ }
1370
+
770
1371
  /** Convert an `AttrValue` to a flat string for reactive dep extraction. */
771
1372
  function attrValueToString(value: AttrValue): string | null {
772
1373
  switch (value.kind) {
@@ -778,7 +1379,11 @@ function attrValueToString(value: AttrValue): string | null {
778
1379
  return value.expr
779
1380
  case 'template':
780
1381
  return value.parts
781
- .map(p => p.type === 'ternary' ? `${p.condition} ${p.whenTrue} ${p.whenFalse}` : '')
1382
+ .map(p => {
1383
+ if (p.type === 'ternary') return `${p.condition} ${p.whenTrue} ${p.whenFalse}`
1384
+ if (p.type === 'lookup') return p.key
1385
+ return ''
1386
+ })
782
1387
  .join(' ')
783
1388
  case 'boolean-attr':
784
1389
  case 'boolean-shorthand':