@pyreon/compiler 0.12.9 → 0.12.11
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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +81 -12
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/jsx.ts +139 -20
- package/src/tests/jsx.test.ts +238 -2
package/lib/types/index.d.ts
CHANGED
|
@@ -41,7 +41,7 @@ interface CompilerWarning {
|
|
|
41
41
|
/** Source file column number (0-based) */
|
|
42
42
|
column: number;
|
|
43
43
|
/** Warning code for filtering */
|
|
44
|
-
code: 'signal-call-in-jsx' | 'missing-key-on-for' | 'signal-in-static-prop';
|
|
44
|
+
code: 'signal-call-in-jsx' | 'missing-key-on-for' | 'signal-in-static-prop' | 'circular-prop-derived';
|
|
45
45
|
}
|
|
46
46
|
interface TransformResult {
|
|
47
47
|
/** Transformed source code (JSX preserved, only expression containers modified) */
|
package/lib/types/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/jsx.ts","../../../src/project-scanner.ts","../../../src/react-intercept.ts"],"mappings":";;AAqCA;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/jsx.ts","../../../src/project-scanner.ts","../../../src/react-intercept.ts"],"mappings":";;AAqCA;;;;;;;;;;AAeA;;;;;;;;;;AAwCA;;;;;;;;;;;;ACrFA;UD8BiB,eAAA;;EAEf,OAAA;EC/BA;EDiCA,IAAA;EC/BA;EDiCA,MAAA;EC/BA;EDiCA,IAAA;AAAA;AAAA,UAOe,eAAA;ECpCA;EDsCf,IAAA;;EAEA,aAAA;ECvCA;EDyCA,QAAA,EAAU,eAAA;AAAA;AAAA,iBAkCI,YAAA,CAAa,IAAA,UAAc,QAAA,YAAyB,eAAA;;;;AAvDpE;;UC9BiB,SAAA;EACf,IAAA;EACA,IAAA;EACA,SAAA;EACA,SAAA;EACA,QAAA;EACA,MAAA;AAAA;AAAA,UAGe,aAAA;EACf,IAAA;EACA,IAAA;EACA,UAAA;EACA,WAAA;EACA,KAAA;AAAA;AAAA,UAGe,UAAA;EACf,IAAA;EACA,IAAA;EACA,OAAA;AAAA;AAAA,UAGe,cAAA;EACf,SAAA;EACA,OAAA;EACA,WAAA;EACA,MAAA,EAAQ,SAAA;EACR,UAAA,EAAY,aAAA;EACZ,OAAA,EAAS,UAAA;AAAA;AAAA,iBAGK,eAAA,CAAgB,GAAA,WAAc,cAAA;;;;ADF9C;;;;;;;;;;AAeA;;KEhCY,mBAAA;AAAA,UA0BK,eAAA;EFQf;EENA,IAAA,EAAM,mBAAA;EFUN;EERA,OAAA;EFQyB;EENzB,IAAA;EFwCc;EEtCd,MAAA;;EAEA,OAAA;EFoC2B;EElC3B,SAAA;EFkCkE;EEhClE,OAAA;AAAA;AAAA,UAGe,eAAA;EACf,IAAA;EACA,IAAA;EACA,WAAA;AAAA;AAAA,UAGe,eAAA;ED9DS;ECgExB,IAAA;ED9DA;ECgEA,WAAA,EAAa,eAAA;ED9Db;ECgEA,OAAA,EAAS,eAAA;AAAA;AAAA,iBAsgBK,mBAAA,CAAoB,IAAA,UAAc,QAAA,YAAyB,eAAA;AAAA,iBAqW3D,gBAAA,CAAiB,IAAA,UAAc,QAAA,YAAyB,eAAA;ADt6BxE;AAAA,iBCg/BgB,gBAAA,CAAiB,IAAA;AAAA,UAuBhB,cAAA;EACf,KAAA;EACA,GAAA;EACA,OAAA;EACA,OAAA;AAAA;;iBAkGc,aAAA,CAAc,KAAA,WAAgB,cAAA"}
|
package/package.json
CHANGED
package/src/jsx.ts
CHANGED
|
@@ -43,7 +43,11 @@ export interface CompilerWarning {
|
|
|
43
43
|
/** Source file column number (0-based) */
|
|
44
44
|
column: number
|
|
45
45
|
/** Warning code for filtering */
|
|
46
|
-
code:
|
|
46
|
+
code:
|
|
47
|
+
| 'signal-call-in-jsx'
|
|
48
|
+
| 'missing-key-on-for'
|
|
49
|
+
| 'signal-in-static-prop'
|
|
50
|
+
| 'circular-prop-derived'
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
export interface TransformResult {
|
|
@@ -117,6 +121,7 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
|
|
|
117
121
|
let needsBindDirectImportGlobal = false
|
|
118
122
|
let needsBindImportGlobal = false
|
|
119
123
|
let needsApplyPropsImportGlobal = false
|
|
124
|
+
let needsMountSlotImportGlobal = false
|
|
120
125
|
|
|
121
126
|
/**
|
|
122
127
|
* If `node` is a fully-static JSX element/fragment, register a module-scope
|
|
@@ -138,7 +143,13 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
|
|
|
138
143
|
function wrap(expr: ts.Expression): void {
|
|
139
144
|
const start = expr.getStart(sf)
|
|
140
145
|
const end = expr.getEnd()
|
|
141
|
-
|
|
146
|
+
// Object literals need parens: `() => { ... }` is a function body with
|
|
147
|
+
// labeled statements, not an object expression. Use `() => ({ ... })`.
|
|
148
|
+
const sliced = sliceExpr(expr)
|
|
149
|
+
const text = ts.isObjectLiteralExpression(expr)
|
|
150
|
+
? `() => (${sliced})`
|
|
151
|
+
: `() => ${sliced}`
|
|
152
|
+
replacements.push({ start, end, text })
|
|
142
153
|
}
|
|
143
154
|
|
|
144
155
|
/** Try to hoist or wrap an expression, pushing a replacement if needed. */
|
|
@@ -227,7 +238,10 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
|
|
|
227
238
|
} else if (shouldWrap(expr)) {
|
|
228
239
|
const start = expr.getStart(sf)
|
|
229
240
|
const end = expr.getEnd()
|
|
230
|
-
|
|
241
|
+
// Object literals need parens to disambiguate from arrow function body
|
|
242
|
+
const sliced = sliceExpr(expr)
|
|
243
|
+
const inner = ts.isObjectLiteralExpression(expr) ? `(${sliced})` : sliced
|
|
244
|
+
replacements.push({ start, end, text: `_rp(() => ${inner})` })
|
|
231
245
|
needsRpImport = true
|
|
232
246
|
}
|
|
233
247
|
} else {
|
|
@@ -352,9 +366,11 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
|
|
|
352
366
|
// Track: const x = props.y ?? z OR const x = own.y
|
|
353
367
|
// Skip let/var — mutable variables can be reassigned, unsafe to inline
|
|
354
368
|
// Skip declarations inside callbacks (map, filter, etc.)
|
|
369
|
+
// Skip stateful calls (signal, computed, effect) — inlining creates new instances
|
|
355
370
|
if (!(node.declarationList.flags & ts.NodeFlags.Const)) continue
|
|
356
371
|
if (_callbackDepth > 0) continue
|
|
357
372
|
if (ts.isIdentifier(decl.name) && decl.initializer) {
|
|
373
|
+
if (isStatefulCall(decl.initializer)) continue
|
|
358
374
|
if (readsFromProps(decl.initializer)) {
|
|
359
375
|
propDerivedVars.set(decl.name.text, decl.initializer)
|
|
360
376
|
}
|
|
@@ -402,25 +418,66 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
|
|
|
402
418
|
// Resolve transitive AST: for each prop-derived var, recursively replace
|
|
403
419
|
// references to other prop-derived vars in its AST with their resolved nodes.
|
|
404
420
|
// Uses ts.visitNode for correct AST transformation — no string manipulation.
|
|
405
|
-
|
|
421
|
+
//
|
|
422
|
+
// The `visited` set prevents infinite recursion on circular references:
|
|
423
|
+
// const a = b + props.x; const b = a + 1;
|
|
424
|
+
// Without it, resolving `a` reaches `b`, which reaches `a` again, and
|
|
425
|
+
// the compiler stack-overflows. The fix: when a variable is already in
|
|
426
|
+
// the visited set, leave the identifier as-is (it falls back to the
|
|
427
|
+
// captured const value, which is the correct runtime behavior for a
|
|
428
|
+
// circular dependency — the variable reads its value at definition time).
|
|
429
|
+
// Track which cycles have been warned about so we don't emit
|
|
430
|
+
// duplicate warnings for the same cycle seen from different vars.
|
|
431
|
+
const warnedCycles = new Set<string>()
|
|
432
|
+
|
|
433
|
+
function resolveExprTransitive(
|
|
434
|
+
node: ts.Expression,
|
|
435
|
+
visited: Set<string> = new Set(),
|
|
436
|
+
/** The source node used for warning locations. */
|
|
437
|
+
sourceNode?: ts.Node,
|
|
438
|
+
): ts.Expression {
|
|
406
439
|
return ts.visitNode(node, function visit(n: ts.Node): ts.Node {
|
|
407
|
-
if (ts.isIdentifier(n) && propDerivedVars.has(n.text)
|
|
408
|
-
|
|
409
|
-
//
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
440
|
+
if (ts.isIdentifier(n) && propDerivedVars.has(n.text)) {
|
|
441
|
+
// Cycle detection: if this variable is already in the visited
|
|
442
|
+
// set, we've found a circular reference. Leave the identifier
|
|
443
|
+
// as-is (falls back to captured const value) and emit a
|
|
444
|
+
// compiler warning so the developer knows reactivity is
|
|
445
|
+
// incomplete on this chain.
|
|
446
|
+
if (visited.has(n.text)) {
|
|
447
|
+
const cycleKey = [...visited, n.text].sort().join(',')
|
|
448
|
+
if (!warnedCycles.has(cycleKey)) {
|
|
449
|
+
warnedCycles.add(cycleKey)
|
|
450
|
+
const chain = [...visited, n.text].join(' → ')
|
|
451
|
+
warn(
|
|
452
|
+
sourceNode ?? n,
|
|
453
|
+
`[Pyreon] Circular prop-derived const reference: ${chain}. ` +
|
|
454
|
+
`The cyclic identifier \`${n.text}\` will use its captured value ` +
|
|
455
|
+
`instead of being reactively inlined. Break the cycle by reading ` +
|
|
456
|
+
`from \`props.*\` directly or restructuring the derivation chain.`,
|
|
457
|
+
'circular-prop-derived',
|
|
458
|
+
)
|
|
459
|
+
}
|
|
415
460
|
return n
|
|
416
461
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
462
|
+
|
|
463
|
+
const parent = n.parent
|
|
464
|
+
// ONLY inline identifiers that are REFERENCES (reads), never DECLARATIONS.
|
|
465
|
+
// An identifier is a declaration when it's the .name of its parent.
|
|
466
|
+
// Skip all non-reference positions to avoid replacing binding names,
|
|
467
|
+
// parameter names, property names, etc. with ParenthesizedExpressions.
|
|
468
|
+
if (parent) {
|
|
469
|
+
// Declaration positions — identifier defines a name, not reads one
|
|
470
|
+
if ('name' in parent && (parent as any).name === n) return n
|
|
471
|
+
// Shorthand property: { x } — the identifier is both key and value
|
|
472
|
+
if (ts.isShorthandPropertyAssignment(parent)) return n
|
|
420
473
|
}
|
|
421
474
|
const resolved = propDerivedVars.get(n.text)!
|
|
475
|
+
// Mark this variable as visited BEFORE recursing so cycles are
|
|
476
|
+
// detected on the next encounter rather than re-entering.
|
|
477
|
+
const nextVisited = new Set(visited)
|
|
478
|
+
nextVisited.add(n.text)
|
|
422
479
|
return ts.factory.createParenthesizedExpression(
|
|
423
|
-
resolveExprTransitive(resolved,
|
|
480
|
+
resolveExprTransitive(resolved, nextVisited, sourceNode),
|
|
424
481
|
)
|
|
425
482
|
}
|
|
426
483
|
return ts.visitEachChild(n, visit, undefined as any)
|
|
@@ -509,6 +566,7 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
|
|
|
509
566
|
if (needsBindDirectImportGlobal) runtimeDomImports.push('_bindDirect')
|
|
510
567
|
if (needsBindTextImportGlobal) runtimeDomImports.push('_bindText')
|
|
511
568
|
if (needsApplyPropsImportGlobal) runtimeDomImports.push('_applyProps')
|
|
569
|
+
if (needsMountSlotImportGlobal) runtimeDomImports.push('_mountSlot')
|
|
512
570
|
const reactivityImports = needsBindImportGlobal
|
|
513
571
|
? `\nimport { _bind } from "@pyreon/reactivity";`
|
|
514
572
|
: ''
|
|
@@ -608,6 +666,7 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
|
|
|
608
666
|
let needsBindTextImport = false
|
|
609
667
|
let needsBindDirectImport = false
|
|
610
668
|
let needsApplyPropsImport = false
|
|
669
|
+
let needsMountSlotImport = false
|
|
611
670
|
|
|
612
671
|
function nextVar(): string {
|
|
613
672
|
return `__e${varIdx++}`
|
|
@@ -635,8 +694,17 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
|
|
|
635
694
|
/** Emit bind line for a ref attribute. */
|
|
636
695
|
function emitRef(attr: ts.JsxAttribute, varName: string): void {
|
|
637
696
|
if (!attr.initializer || !ts.isJsxExpression(attr.initializer)) return
|
|
638
|
-
|
|
639
|
-
|
|
697
|
+
const expr = attr.initializer.expression
|
|
698
|
+
if (!expr) return
|
|
699
|
+
// Function ref: ref={(el) => { ... }} or ref={fn} → call with element
|
|
700
|
+
// Object ref: ref={myRef} → assign element to .current
|
|
701
|
+
if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) {
|
|
702
|
+
bindLines.push(`(${sliceExpr(expr)})(${varName})`)
|
|
703
|
+
} else {
|
|
704
|
+
bindLines.push(
|
|
705
|
+
`{ const __r = ${sliceExpr(expr)}; if (typeof __r === "function") __r(${varName}); else if (__r) __r.current = ${varName} }`,
|
|
706
|
+
)
|
|
707
|
+
}
|
|
640
708
|
}
|
|
641
709
|
|
|
642
710
|
/** Emit event handler bind line — delegated (expando) or addEventListener. */
|
|
@@ -884,6 +952,17 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
|
|
|
884
952
|
// expression
|
|
885
953
|
const needsPlaceholder = useMixed || useMultiExpr
|
|
886
954
|
const { expr, isReactive } = unwrapAccessor(child.expression)
|
|
955
|
+
|
|
956
|
+
// Children slot: expression accesses .children (e.g. props.children, own.children)
|
|
957
|
+
// These can contain VNodes — use _mountSlot instead of text node binding.
|
|
958
|
+
if (isChildrenExpression(child.expression, expr)) {
|
|
959
|
+
needsMountSlotImport = true
|
|
960
|
+
const placeholder = `${parentRef}.childNodes[${childNodeIdx}]`
|
|
961
|
+
const d = nextDisp()
|
|
962
|
+
bindLines.push(`const ${d} = _mountSlot(${expr}, ${parentRef}, ${placeholder})`)
|
|
963
|
+
return '<!>'
|
|
964
|
+
}
|
|
965
|
+
|
|
887
966
|
if (isReactive) {
|
|
888
967
|
return emitReactiveTextChild(
|
|
889
968
|
expr,
|
|
@@ -951,6 +1030,7 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
|
|
|
951
1030
|
if (needsBindTextImport) needsBindTextImportGlobal = true
|
|
952
1031
|
if (needsBindDirectImport) needsBindDirectImportGlobal = true
|
|
953
1032
|
if (needsApplyPropsImport) needsApplyPropsImportGlobal = true
|
|
1033
|
+
if (needsMountSlotImport) needsMountSlotImportGlobal = true
|
|
954
1034
|
|
|
955
1035
|
// Build bind function body
|
|
956
1036
|
const escaped = html.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
@@ -1064,7 +1144,7 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
|
|
|
1064
1144
|
function sliceExpr(expr: ts.Expression): string {
|
|
1065
1145
|
// Quick check: does this expression contain any prop-derived references?
|
|
1066
1146
|
if (propDerivedVars.size > 0 && accessesProps(expr)) {
|
|
1067
|
-
const resolved = resolveExprTransitive(expr)
|
|
1147
|
+
const resolved = resolveExprTransitive(expr, new Set(), expr)
|
|
1068
1148
|
return printer.printNode(ts.EmitHint.Expression, resolved, sf)
|
|
1069
1149
|
}
|
|
1070
1150
|
return code.slice(expr.getStart(sf), expr.getEnd())
|
|
@@ -1110,6 +1190,41 @@ const JSX_TO_HTML_ATTR: Record<string, string> = {
|
|
|
1110
1190
|
htmlFor: 'for',
|
|
1111
1191
|
}
|
|
1112
1192
|
|
|
1193
|
+
/**
|
|
1194
|
+
* Detect if an expression is a stateful call that must NOT be inlined.
|
|
1195
|
+
* signal(), computed(), effect() etc. create state — inlining them would
|
|
1196
|
+
* create new instances at each use site instead of referencing the original.
|
|
1197
|
+
*/
|
|
1198
|
+
const STATEFUL_CALLS = new Set([
|
|
1199
|
+
'signal', 'computed', 'effect', 'batch',
|
|
1200
|
+
'createContext', 'createReactiveContext',
|
|
1201
|
+
'useContext', 'useRef', 'createRef',
|
|
1202
|
+
'useForm', 'useQuery', 'useMutation',
|
|
1203
|
+
'defineStore', 'useStore',
|
|
1204
|
+
])
|
|
1205
|
+
|
|
1206
|
+
function isStatefulCall(node: ts.Node): boolean {
|
|
1207
|
+
if (!ts.isCallExpression(node)) return false
|
|
1208
|
+
const callee = node.expression
|
|
1209
|
+
if (ts.isIdentifier(callee)) return STATEFUL_CALLS.has(callee.text)
|
|
1210
|
+
return false
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Detect if an expression accesses `.children` — these can contain VNodes
|
|
1215
|
+
* and must use _mountSlot instead of text node binding in templates.
|
|
1216
|
+
* Matches: props.children, own.children, x.children, or bare `children` identifier.
|
|
1217
|
+
*/
|
|
1218
|
+
function isChildrenExpression(node: ts.Expression, expr: string): boolean {
|
|
1219
|
+
// Direct property access: props.children, own.children
|
|
1220
|
+
if (ts.isPropertyAccessExpression(node) && node.name.text === 'children') return true
|
|
1221
|
+
// Bare identifier named 'children'
|
|
1222
|
+
if (ts.isIdentifier(node) && node.text === 'children') return true
|
|
1223
|
+
// String fallback for inlined expressions
|
|
1224
|
+
if (expr.endsWith('.children') || expr === 'children') return true
|
|
1225
|
+
return false
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1113
1228
|
function isLowerCase(s: string): boolean {
|
|
1114
1229
|
return s.length > 0 && s[0] === s[0]?.toLowerCase()
|
|
1115
1230
|
}
|
|
@@ -1126,7 +1241,11 @@ function escapeHtmlAttr(s: string): string {
|
|
|
1126
1241
|
}
|
|
1127
1242
|
|
|
1128
1243
|
function escapeHtmlText(s: string): string {
|
|
1129
|
-
|
|
1244
|
+
// TypeScript's JsxText preserves HTML entities as-is (e.g. "<" stays "<",
|
|
1245
|
+
// not decoded to "<"). Since the template is parsed via innerHTML, entities are
|
|
1246
|
+
// already valid HTML — pass them through. Only escape raw `<` and raw `&` that
|
|
1247
|
+
// are NOT part of existing entities.
|
|
1248
|
+
return s.replace(/&(?!(?:#\d+|#x[\da-fA-F]+|[a-zA-Z]\w*);)/g, '&').replace(/</g, '<')
|
|
1130
1249
|
}
|
|
1131
1250
|
|
|
1132
1251
|
// ─── Static JSX analysis ──────────────────────────────────────────────────────
|
package/src/tests/jsx.test.ts
CHANGED
|
@@ -766,9 +766,17 @@ describe('JSX transform — template emission', () => {
|
|
|
766
766
|
expect(result).toContain('setAttribute("title"')
|
|
767
767
|
})
|
|
768
768
|
|
|
769
|
-
test('ref attribute in template binds .current', () => {
|
|
769
|
+
test('ref attribute in template binds .current for object refs', () => {
|
|
770
770
|
const result = t('<div ref={myRef}><span /></div>')
|
|
771
|
-
|
|
771
|
+
// Object refs go through a runtime check (could also be a function)
|
|
772
|
+
expect(result).toContain('myRef')
|
|
773
|
+
expect(result).toContain('.current = __root')
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
test('ref attribute in template calls function refs', () => {
|
|
777
|
+
const result = t('<div ref={(el) => { myEl = el }}><span /></div>')
|
|
778
|
+
// Arrow function refs are called with the element
|
|
779
|
+
expect(result).toContain('((el) => { myEl = el })(__root)')
|
|
772
780
|
})
|
|
773
781
|
|
|
774
782
|
test('handles non-void self-closing element as closing tag', () => {
|
|
@@ -1417,4 +1425,232 @@ describe('JSX transform — AST inlining (template literals, ternaries)', () =>
|
|
|
1417
1425
|
const result = t('function C(props) { const key = props.key; return <div>{obj[key]}</div> }')
|
|
1418
1426
|
expect(result).toContain('props.key')
|
|
1419
1427
|
})
|
|
1428
|
+
|
|
1429
|
+
test('type cast "as" in prop-derived var does not crash compiler', () => {
|
|
1430
|
+
// Regression: const sel = state.selected() as string[] inside JSX arrow
|
|
1431
|
+
// caused ParenthesizedExpression to replace a BindingName, crashing ts.visitEachChild
|
|
1432
|
+
const result = t(`
|
|
1433
|
+
function C(props) {
|
|
1434
|
+
const items = props.data
|
|
1435
|
+
return <div>{() => {
|
|
1436
|
+
const sel = items as any
|
|
1437
|
+
return sel
|
|
1438
|
+
}}</div>
|
|
1439
|
+
}
|
|
1440
|
+
`)
|
|
1441
|
+
expect(result).toBeDefined()
|
|
1442
|
+
})
|
|
1443
|
+
|
|
1444
|
+
test('variable declaration name is not inlined by resolveExprTransitive', () => {
|
|
1445
|
+
const result = t(`
|
|
1446
|
+
function C(props) {
|
|
1447
|
+
const x = props.val
|
|
1448
|
+
const y = x
|
|
1449
|
+
return <div>{y}</div>
|
|
1450
|
+
}
|
|
1451
|
+
`)
|
|
1452
|
+
expect(result).toContain('props.val')
|
|
1453
|
+
})
|
|
1454
|
+
|
|
1455
|
+
test('parameter name matching prop-derived var does not crash', () => {
|
|
1456
|
+
// (val: string) => ... where "val" might match a prop-derived var
|
|
1457
|
+
const result = t(`
|
|
1458
|
+
function C(props) {
|
|
1459
|
+
const val = props.value
|
|
1460
|
+
return <div>{() => [1,2].map((val) => <span>{val}</span>)}</div>
|
|
1461
|
+
}
|
|
1462
|
+
`)
|
|
1463
|
+
expect(result).toBeDefined()
|
|
1464
|
+
})
|
|
1465
|
+
|
|
1466
|
+
test('catch clause variable matching prop-derived var does not crash', () => {
|
|
1467
|
+
const result = t(`
|
|
1468
|
+
function C(props) {
|
|
1469
|
+
const err = props.error
|
|
1470
|
+
return <div>{() => { try {} catch(err) { return err } }}</div>
|
|
1471
|
+
}
|
|
1472
|
+
`)
|
|
1473
|
+
expect(result).toBeDefined()
|
|
1474
|
+
})
|
|
1475
|
+
|
|
1476
|
+
test('binding element matching prop-derived var does not crash', () => {
|
|
1477
|
+
const result = t(`
|
|
1478
|
+
function C(props) {
|
|
1479
|
+
const x = props.x
|
|
1480
|
+
return <div>{() => { const { x } = obj; return x }}</div>
|
|
1481
|
+
}
|
|
1482
|
+
`)
|
|
1483
|
+
expect(result).toBeDefined()
|
|
1484
|
+
})
|
|
1485
|
+
|
|
1486
|
+
test('HTML entities in JSX text are not double-escaped', () => {
|
|
1487
|
+
const result = t('function C() { return <button>< prev</button> }')
|
|
1488
|
+
expect(result).toContain('<')
|
|
1489
|
+
expect(result).not.toContain('&lt;')
|
|
1490
|
+
})
|
|
1491
|
+
|
|
1492
|
+
test('mixed HTML entities and raw ampersands', () => {
|
|
1493
|
+
const result = t('function C() { return <span>A & B < C</span> }')
|
|
1494
|
+
expect(result).toContain('&')
|
|
1495
|
+
expect(result).toContain('<')
|
|
1496
|
+
expect(result).not.toContain('&amp;')
|
|
1497
|
+
expect(result).not.toContain('&lt;')
|
|
1498
|
+
})
|
|
1499
|
+
|
|
1500
|
+
test('props.children in template uses _mountSlot instead of createTextNode', () => {
|
|
1501
|
+
const result = t('function C(props) { return <div>{props.children}</div> }')
|
|
1502
|
+
expect(result).toContain('_mountSlot')
|
|
1503
|
+
expect(result).not.toContain('createTextNode')
|
|
1504
|
+
expect(result).not.toContain('.data')
|
|
1505
|
+
})
|
|
1506
|
+
|
|
1507
|
+
test('own.children in template uses _mountSlot', () => {
|
|
1508
|
+
const result = t('function C(props) { const own = props; return <label><input/>{own.children}</label> }')
|
|
1509
|
+
expect(result).toContain('_mountSlot')
|
|
1510
|
+
expect(result).toContain('own.children')
|
|
1511
|
+
})
|
|
1512
|
+
|
|
1513
|
+
test('non-children prop access still uses text node binding', () => {
|
|
1514
|
+
const result = t('function C(props) { return <div>{props.name}</div> }')
|
|
1515
|
+
expect(result).not.toContain('_mountSlot')
|
|
1516
|
+
expect(result).toContain('.data')
|
|
1517
|
+
})
|
|
1518
|
+
|
|
1519
|
+
test('signal() calls are NOT inlined as prop-derived vars', () => {
|
|
1520
|
+
const result = t(`
|
|
1521
|
+
function C(props) {
|
|
1522
|
+
const open = signal(props.defaultOpen ?? false)
|
|
1523
|
+
return <div>{() => open() ? 'yes' : 'no'}</div>
|
|
1524
|
+
}
|
|
1525
|
+
`)
|
|
1526
|
+
// open should be referenced as-is, NOT replaced with signal(props.defaultOpen ?? false)
|
|
1527
|
+
expect(result).toContain('open()')
|
|
1528
|
+
// signal() should appear only once — in the original declaration
|
|
1529
|
+
const signalMatches = result.match(/signal\(props\.defaultOpen/g)
|
|
1530
|
+
expect(signalMatches?.length ?? 0).toBe(1)
|
|
1531
|
+
})
|
|
1532
|
+
})
|
|
1533
|
+
|
|
1534
|
+
// ─── Circular reference safety ─────────────────────────────────────────────
|
|
1535
|
+
|
|
1536
|
+
describe('JSX transform — circular prop-derived var cycles do not crash', () => {
|
|
1537
|
+
// Before the fix (PR #204), resolveExprTransitive used a single
|
|
1538
|
+
// `excludeVar` parameter that only prevented immediate re-entry on the
|
|
1539
|
+
// same variable. Multi-step cycles (a → b → a) alternated between
|
|
1540
|
+
// the two identifiers and recursed infinitely, crashing the compiler
|
|
1541
|
+
// with "Maximum call stack size exceeded."
|
|
1542
|
+
//
|
|
1543
|
+
// The fix replaces `excludeVar` with a `visited: Set<string>` that
|
|
1544
|
+
// tracks the entire call stack. When a variable is already visited,
|
|
1545
|
+
// the identifier is left as-is (falls back to the captured const
|
|
1546
|
+
// value at runtime) and a compiler warning is emitted.
|
|
1547
|
+
|
|
1548
|
+
// Use transformJSX directly (not the `t` helper) so we can assert
|
|
1549
|
+
// on both the code AND the warnings.
|
|
1550
|
+
const full = (code: string) => transformJSX(code, 'input.tsx')
|
|
1551
|
+
|
|
1552
|
+
test('two-variable cycle: a ↔ b does not stack-overflow and emits warning', () => {
|
|
1553
|
+
const result = full(`
|
|
1554
|
+
function Comp(props) {
|
|
1555
|
+
const a = b + props.x;
|
|
1556
|
+
const b = a + 1;
|
|
1557
|
+
return <div>{a}</div>
|
|
1558
|
+
}
|
|
1559
|
+
`)
|
|
1560
|
+
// Should compile (not crash) and produce valid output
|
|
1561
|
+
expect(result.code).toBeDefined()
|
|
1562
|
+
expect(result.code).toContain('_tpl')
|
|
1563
|
+
|
|
1564
|
+
// Should emit a circular-prop-derived warning
|
|
1565
|
+
const cycleWarnings = result.warnings.filter((w) => w.code === 'circular-prop-derived')
|
|
1566
|
+
expect(cycleWarnings.length).toBeGreaterThanOrEqual(1)
|
|
1567
|
+
expect(cycleWarnings[0]?.message).toMatch(/Circular prop-derived/)
|
|
1568
|
+
})
|
|
1569
|
+
|
|
1570
|
+
test('three-variable cycle: a → b → c → a does not stack-overflow', () => {
|
|
1571
|
+
const result = full(`
|
|
1572
|
+
function Comp(props) {
|
|
1573
|
+
const a = c + props.x;
|
|
1574
|
+
const b = a + 1;
|
|
1575
|
+
const c = b + 2;
|
|
1576
|
+
return <div>{a}</div>
|
|
1577
|
+
}
|
|
1578
|
+
`)
|
|
1579
|
+
expect(result.code).toBeDefined()
|
|
1580
|
+
expect(result.code).toContain('_tpl')
|
|
1581
|
+
|
|
1582
|
+
const cycleWarnings = result.warnings.filter((w) => w.code === 'circular-prop-derived')
|
|
1583
|
+
expect(cycleWarnings.length).toBeGreaterThanOrEqual(1)
|
|
1584
|
+
})
|
|
1585
|
+
|
|
1586
|
+
test('self-referencing variable does not stack-overflow', () => {
|
|
1587
|
+
const result = full(`
|
|
1588
|
+
function Comp(props) {
|
|
1589
|
+
const a = a + props.x;
|
|
1590
|
+
return <div>{a}</div>
|
|
1591
|
+
}
|
|
1592
|
+
`)
|
|
1593
|
+
expect(result.code).toBeDefined()
|
|
1594
|
+
|
|
1595
|
+
const cycleWarnings = result.warnings.filter((w) => w.code === 'circular-prop-derived')
|
|
1596
|
+
expect(cycleWarnings.length).toBeGreaterThanOrEqual(1)
|
|
1597
|
+
expect(cycleWarnings[0]?.message).toContain('a')
|
|
1598
|
+
})
|
|
1599
|
+
|
|
1600
|
+
test('cycle still inlines the non-cyclic parts correctly', () => {
|
|
1601
|
+
// const a = b + props.x; const b = a + 1;
|
|
1602
|
+
// When resolving `a` for JSX: `b + props.x` → `b` is cycle-broken
|
|
1603
|
+
// (left as identifier `b`), `props.x` is inlined as `props.x`.
|
|
1604
|
+
const result = full(`
|
|
1605
|
+
function Comp(props) {
|
|
1606
|
+
const a = b + props.x;
|
|
1607
|
+
const b = a + 1;
|
|
1608
|
+
return <div>{a}</div>
|
|
1609
|
+
}
|
|
1610
|
+
`)
|
|
1611
|
+
// Non-cyclic part (props.x) is still inlined reactively
|
|
1612
|
+
expect(result.code).toContain('props.x')
|
|
1613
|
+
expect(result.code).toContain('_bind')
|
|
1614
|
+
// Cyclic identifier `b` is left as-is (not further resolved)
|
|
1615
|
+
// The _bind should reference `b` directly since it's the cycle-break point
|
|
1616
|
+
expect(result.code).toMatch(/\bb\b/)
|
|
1617
|
+
})
|
|
1618
|
+
|
|
1619
|
+
test('non-cyclic deep chain still works after the cycle fix', () => {
|
|
1620
|
+
// Regression guard: the visited-set fix must NOT break the existing
|
|
1621
|
+
// non-cyclic transitive resolution (a → b → c, no cycle).
|
|
1622
|
+
const result = full(`
|
|
1623
|
+
function Comp(props) {
|
|
1624
|
+
const a = props.x;
|
|
1625
|
+
const b = a + 1;
|
|
1626
|
+
const c = b * 2;
|
|
1627
|
+
return <div>{c}</div>
|
|
1628
|
+
}
|
|
1629
|
+
`)
|
|
1630
|
+
expect(result.code).toContain('props.x')
|
|
1631
|
+
expect(result.code).toContain('_bind')
|
|
1632
|
+
// c should be fully inlined — not left as a static reference
|
|
1633
|
+
expect(result.code).not.toMatch(/__t\d+\.data = c\b/)
|
|
1634
|
+
// No cycle warnings on a non-cyclic chain
|
|
1635
|
+
const cycleWarnings = result.warnings.filter((w) => w.code === 'circular-prop-derived')
|
|
1636
|
+
expect(cycleWarnings.length).toBe(0)
|
|
1637
|
+
})
|
|
1638
|
+
|
|
1639
|
+
test('warning message includes the cycle chain for debugging', () => {
|
|
1640
|
+
const result = full(`
|
|
1641
|
+
function Comp(props) {
|
|
1642
|
+
const a = b + props.x;
|
|
1643
|
+
const b = a + 1;
|
|
1644
|
+
return <div>{a}</div>
|
|
1645
|
+
}
|
|
1646
|
+
`)
|
|
1647
|
+
const cycleWarnings = result.warnings.filter((w) => w.code === 'circular-prop-derived')
|
|
1648
|
+
expect(cycleWarnings.length).toBeGreaterThanOrEqual(1)
|
|
1649
|
+
// The warning should mention both variables in the cycle chain
|
|
1650
|
+
const msg = cycleWarnings[0]!.message
|
|
1651
|
+
expect(msg).toContain('a')
|
|
1652
|
+
expect(msg).toContain('b')
|
|
1653
|
+
// And suggest how to fix it
|
|
1654
|
+
expect(msg).toContain('props.*')
|
|
1655
|
+
})
|
|
1420
1656
|
})
|