@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.
@@ -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) */
@@ -1 +1 @@
1
- {"version":3,"file":"index2.d.ts","names":[],"sources":["../../../src/jsx.ts","../../../src/project-scanner.ts","../../../src/react-intercept.ts"],"mappings":";;AAqCA;;;;;;;;;;AAWA;;;;;;;;;;AAwCA;;;;;;;;;;;;ACjFA;UD8BiB,eAAA;;EAEf,OAAA;EC/BA;EDiCA,IAAA;EC/BA;EDiCA,MAAA;EC/BA;EDiCA,IAAA;AAAA;AAAA,UAGe,eAAA;EChCA;EDkCf,IAAA;;EAEA,aAAA;ECnCA;EDqCA,QAAA,EAAU,eAAA;AAAA;AAAA,iBAkCI,YAAA,CAAa,IAAA,UAAc,QAAA,YAAyB,eAAA;;;;AAnDpE;;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;;;;;;;;;;AAWA;;KE5BY,mBAAA;AAAA,UA0BK,eAAA;EFIf;EEFA,IAAA,EAAM,mBAAA;EFMN;EEJA,OAAA;EFIyB;EEFzB,IAAA;EFoCc;EElCd,MAAA;;EAEA,OAAA;EFgC2B;EE9B3B,SAAA;EF8BkE;EE5BlE,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"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/compiler",
3
- "version": "0.12.9",
3
+ "version": "0.12.11",
4
4
  "description": "Template and JSX compiler for Pyreon",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/compiler#readme",
6
6
  "bugs": {
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: 'signal-call-in-jsx' | 'missing-key-on-for' | 'signal-in-static-prop'
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
- replacements.push({ start, end, text: `() => ${sliceExpr(expr)}` })
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
- replacements.push({ start, end, text: `_rp(() => ${sliceExpr(expr)})` })
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
- function resolveExprTransitive(node: ts.Expression, excludeVar?: string): ts.Expression {
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) && n.text !== excludeVar) {
408
- const parent = n.parent
409
- // Skip property name after dot: obj.sizes
410
- if (parent && ts.isPropertyAccessExpression(parent) && parent.name === n) {
411
- return n
412
- }
413
- // Skip JSX attribute name: sizes={...}
414
- if (parent && ts.isJsxAttribute(parent) && parent.name === n) {
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
- // Skip shorthand property assignment: { sizes }
418
- if (parent && ts.isShorthandPropertyAssignment(parent)) {
419
- return n
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, n.text),
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
- if (!attr.initializer.expression) return
639
- bindLines.push(`${sliceExpr(attr.initializer.expression)}.current = ${varName}`)
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
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
1244
+ // TypeScript's JsxText preserves HTML entities as-is (e.g. "&lt;" stays "&lt;",
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, '&amp;').replace(/</g, '&lt;')
1130
1249
  }
1131
1250
 
1132
1251
  // ─── Static JSX analysis ──────────────────────────────────────────────────────
@@ -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
- expect(result).toContain('myRef.current = __root')
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>&lt; prev</button> }')
1488
+ expect(result).toContain('&lt;')
1489
+ expect(result).not.toContain('&amp;lt;')
1490
+ })
1491
+
1492
+ test('mixed HTML entities and raw ampersands', () => {
1493
+ const result = t('function C() { return <span>A &amp; B &lt; C</span> }')
1494
+ expect(result).toContain('&amp;')
1495
+ expect(result).toContain('&lt;')
1496
+ expect(result).not.toContain('&amp;amp;')
1497
+ expect(result).not.toContain('&amp;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
  })