@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.
- package/dist/debug.d.ts +66 -1
- package/dist/debug.d.ts.map +1 -1
- package/dist/html-constants.d.ts +4 -9
- package/dist/html-constants.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8628 -8071
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +15 -0
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +5 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +2 -8
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/prop-rewrite.d.ts.map +1 -1
- package/dist/types.d.ts +20 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +135 -0
- package/src/__tests__/boolean-attributes.test.ts +2 -1
- package/src/__tests__/conditional-branch-reactive-text.test.ts +108 -0
- package/src/__tests__/debug.test.ts +422 -9
- package/src/__tests__/doc-examples.test.ts +7 -0
- package/src/__tests__/ir-provider.test.ts +98 -0
- package/src/__tests__/rewrite-destructured-props.test.ts +73 -0
- package/src/debug.ts +637 -32
- package/src/html-constants.ts +4 -27
- package/src/index.ts +6 -1
- package/src/ir-to-client-js/collect-elements.ts +3 -0
- package/src/ir-to-client-js/emit-reactive.ts +5 -5
- package/src/ir-to-client-js/html-template.ts +97 -11
- package/src/ir-to-client-js/types.ts +6 -0
- package/src/ir-to-client-js/utils.ts +4 -65
- package/src/jsx-to-ir.ts +92 -17
- package/src/prop-rewrite.ts +6 -2
- 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
|
-
|
|
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
|
-
|
|
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 =>
|
|
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':
|