@pyreon/compiler 0.12.4 → 0.12.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/compiler",
3
- "version": "0.12.4",
3
+ "version": "0.12.6",
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
@@ -116,6 +116,7 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
116
116
  let needsBindTextImportGlobal = false
117
117
  let needsBindDirectImportGlobal = false
118
118
  let needsBindImportGlobal = false
119
+ let needsApplyPropsImportGlobal = false
119
120
 
120
121
  /**
121
122
  * If `node` is a fully-static JSX element/fragment, register a module-scope
@@ -137,7 +138,8 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
137
138
  function wrap(expr: ts.Expression): void {
138
139
  const start = expr.getStart(sf)
139
140
  const end = expr.getEnd()
140
- replacements.push({ start, end, text: `() => ${code.slice(start, end)}` })
141
+ const exprText = inlineVarsInText(code.slice(start, end))
142
+ replacements.push({ start, end, text: `() => ${exprText}` })
141
143
  }
142
144
 
143
145
  /** Try to hoist or wrap an expression, pushing a replacement if needed. */
@@ -154,7 +156,7 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
154
156
 
155
157
  /** Try to emit a template for a JsxElement. Returns true if handled. */
156
158
  function tryTemplateEmit(node: ts.JsxElement): boolean {
157
- const elemCount = templateElementCount(node)
159
+ const elemCount = templateElementCount(node, /* isRoot */ true)
158
160
  if (elemCount < 1) return false
159
161
  const tplCall = buildTemplateCall(node)
160
162
  if (!tplCall) return false
@@ -254,6 +256,223 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
254
256
  ts.forEachChild(expr, walk)
255
257
  }
256
258
 
259
+ // ── Prop-derived variable tracking ─────────────────────────────────────────
260
+ // Pre-pass: find variables derived from props/splitProps results inside
261
+ // component functions. These are inlined at JSX use sites so the compiler's
262
+ // existing wrapping makes them reactive.
263
+ //
264
+ // Example:
265
+ // const align = props.alignX ?? 'left'
266
+ // return <div class={align}> ← inlined to: class={props.alignX ?? 'left'}
267
+ // ← compiler wraps: class={() => props.alignX ?? 'left'}
268
+
269
+ /** Names that refer to the props object or splitProps results. */
270
+ const propsNames = new Set<string>()
271
+
272
+ /** Map of variable name → source text of the original expression. */
273
+ const propDerivedVars = new Map<string, string>()
274
+
275
+ /** Check if an expression reads from a tracked props-like object. */
276
+ function readsFromProps(node: ts.Node): boolean {
277
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
278
+ return propsNames.has(node.expression.text)
279
+ }
280
+ if (ts.isElementAccessExpression(node) && ts.isIdentifier(node.expression)) {
281
+ return propsNames.has(node.expression.text)
282
+ }
283
+ // Check children recursively — e.g. props.x ?? 'default'
284
+ let found = false
285
+ ts.forEachChild(node, (child) => {
286
+ if (found) return
287
+ if (readsFromProps(child)) found = true
288
+ })
289
+ return found
290
+ }
291
+
292
+ /** Pre-pass: scan a function body for prop-derived variable declarations.
293
+ * callbackDepth tracks nesting inside callback arguments (map, filter, etc.)
294
+ * to avoid tracking variables declared inside callbacks as prop-derived. */
295
+ let _callbackDepth = 0
296
+ function scanForPropDerivedVars(node: ts.Node): void {
297
+ // Track callback nesting — don't track vars inside callbacks
298
+ if ((ts.isArrowFunction(node) || ts.isFunctionExpression(node))) {
299
+ const parent = node.parent
300
+ if (parent && ts.isCallExpression(parent) && parent.arguments.includes(node as any)) {
301
+ _callbackDepth++
302
+ ts.forEachChild(node, scanForPropDerivedVars)
303
+ _callbackDepth--
304
+ return
305
+ }
306
+ }
307
+ // Track the function's first parameter as a props name.
308
+ // Only for COMPONENT functions — not callbacks like .map(item => <div>...)
309
+ // Heuristic: component functions are named declarations, const assignments,
310
+ // or export defaults — NOT inline arguments to calls like .map(), .filter().
311
+ if ((ts.isFunctionDeclaration(node) || ts.isArrowFunction(node) || ts.isFunctionExpression(node))
312
+ && node.parameters.length > 0) {
313
+
314
+ // Skip functions that are arguments to a call (map/filter callbacks)
315
+ const parent = node.parent
316
+ if (parent && ts.isCallExpression(parent) && parent.arguments.includes(node as any)) {
317
+ ts.forEachChild(node, scanForPropDerivedVars)
318
+ return
319
+ }
320
+
321
+ const firstParam = node.parameters[0]!
322
+ if (ts.isIdentifier(firstParam.name)) {
323
+ // Check if this function returns JSX (is a component)
324
+ let hasJSX = false
325
+ ts.forEachChild(node, function checkJSX(n) {
326
+ if (hasJSX) return
327
+ if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) {
328
+ hasJSX = true
329
+ return
330
+ }
331
+ ts.forEachChild(n, checkJSX)
332
+ })
333
+ if (hasJSX) propsNames.add(firstParam.name.text)
334
+ }
335
+ }
336
+
337
+ // Track splitProps results: const [own, rest] = splitProps(props, [...])
338
+ if (ts.isVariableStatement(node)) {
339
+ for (const decl of node.declarationList.declarations) {
340
+ if (ts.isArrayBindingPattern(decl.name) && decl.initializer
341
+ && ts.isCallExpression(decl.initializer)) {
342
+ const callee = decl.initializer.expression
343
+ if (ts.isIdentifier(callee) && callee.text === 'splitProps') {
344
+ for (const el of decl.name.elements) {
345
+ if (ts.isBindingElement(el) && ts.isIdentifier(el.name)) {
346
+ propsNames.add(el.name.text)
347
+ }
348
+ }
349
+ }
350
+ }
351
+
352
+ // Track: const x = props.y ?? z OR const x = own.y
353
+ // Skip let/var — mutable variables can be reassigned, unsafe to inline
354
+ // Skip declarations inside callbacks (map, filter, etc.)
355
+ if (!(node.declarationList.flags & ts.NodeFlags.Const)) continue
356
+ if (_callbackDepth > 0) continue
357
+ if (ts.isIdentifier(decl.name) && decl.initializer) {
358
+ if (readsFromProps(decl.initializer)) {
359
+ const varName = decl.name.text
360
+ const exprText = code.slice(decl.initializer.getStart(sf), decl.initializer.getEnd())
361
+ propDerivedVars.set(varName, exprText)
362
+ }
363
+ }
364
+ }
365
+ }
366
+
367
+ ts.forEachChild(node, scanForPropDerivedVars)
368
+ }
369
+
370
+ // Run pre-pass
371
+ scanForPropDerivedVars(sf)
372
+
373
+ // Transitive resolution: if const b = a + 1 where a is prop-derived,
374
+ // then b is also prop-derived. Inline b → (a + 1) → ((props.x) + 1).
375
+ // Fixed-point iteration until no new variables are added.
376
+ let changed = true
377
+ while (changed) {
378
+ changed = false
379
+ sf.forEachChild(function scanTransitive(node) {
380
+ if (!ts.isVariableStatement(node)) { ts.forEachChild(node, scanTransitive); return }
381
+ for (const decl of node.declarationList.declarations) {
382
+ if (!ts.isIdentifier(decl.name) || !decl.initializer) continue
383
+ const varName = decl.name.text
384
+ if (propDerivedVars.has(varName)) continue // already tracked
385
+ if (node.declarationList.flags & ts.NodeFlags.Let) continue // skip let
386
+ // Check if the initializer references any existing prop-derived var
387
+ let usesPropVar = false
388
+ ts.forEachChild(decl.initializer, function check(n) {
389
+ if (usesPropVar) return
390
+ if (ts.isIdentifier(n) && propDerivedVars.has(n.text)) {
391
+ const parent = n.parent
392
+ if (parent && ts.isPropertyAccessExpression(parent) && parent.name === n) return
393
+ usesPropVar = true
394
+ }
395
+ ts.forEachChild(n, check)
396
+ })
397
+ if (usesPropVar) {
398
+ const exprText = code.slice(decl.initializer.getStart(sf), decl.initializer.getEnd())
399
+ propDerivedVars.set(varName, exprText)
400
+ changed = true
401
+ }
402
+ }
403
+ })
404
+ }
405
+
406
+ // Resolve transitive inlining: if b's expr references a, replace a with its expr
407
+ for (const [varName, expr] of propDerivedVars) {
408
+ let resolved = expr
409
+ for (const [depName, depExpr] of propDerivedVars) {
410
+ if (depName === varName) continue
411
+ const re = new RegExp(`(?<![.\\w])${depName}(?![\\w:])`, 'g')
412
+ if (re.test(resolved)) {
413
+ resolved = resolved.replace(re, `(${depExpr})`)
414
+ }
415
+ }
416
+ if (resolved !== expr) propDerivedVars.set(varName, resolved)
417
+ }
418
+
419
+ /**
420
+ * Enhanced dynamic check — combines containsCall with props awareness.
421
+ * Returns true if an expression is reactive (contains signal calls,
422
+ * accesses props members, or references prop-derived variables).
423
+ */
424
+ /**
425
+ * Replace prop-derived variable names in a source text with their original expressions.
426
+ * Simple regex-based replacement — safe because variable names are identifiers.
427
+ */
428
+ function inlineVarsInText(text: string): string {
429
+ if (propDerivedVars.size === 0) return text
430
+ let result = text
431
+ for (const [varName, expr] of propDerivedVars) {
432
+ // Replace standalone variable references only.
433
+ // Must NOT match:
434
+ // - property access: obj.varName (preceded by .)
435
+ // - property name in object: { varName: ... } (followed by :)
436
+ // - part of longer identifier: varNameExtra
437
+ // Lookbehind ensures not preceded by . or alphanumeric
438
+ // Lookahead ensures not followed by alphanumeric or :
439
+ const re = new RegExp(`(?<![.\\w])${varName}(?![\\w:])`, 'g')
440
+ result = result.replace(re, `(${expr})`)
441
+ }
442
+ return result
443
+ }
444
+
445
+ function isDynamic(node: ts.Node): boolean {
446
+ if (containsCall(node)) return true
447
+ return accessesProps(node)
448
+ }
449
+
450
+ /** Check if an expression accesses a tracked props object or a prop-derived variable. */
451
+ function accessesProps(node: ts.Node): boolean {
452
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
453
+ if (propsNames.has(node.expression.text)) return true
454
+ }
455
+ if (ts.isIdentifier(node) && propDerivedVars.has(node.text)) {
456
+ const parent = node.parent
457
+ if (parent && ts.isPropertyAccessExpression(parent) && parent.name === node) return false
458
+ return true
459
+ }
460
+ let found = false
461
+ ts.forEachChild(node, (child) => {
462
+ if (found) return
463
+ if (ts.isArrowFunction(child) || ts.isFunctionExpression(child)) return
464
+ if (accessesProps(child)) found = true
465
+ })
466
+ return found
467
+ }
468
+
469
+ function shouldWrap(node: ts.Expression): boolean {
470
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return false
471
+ if (isStatic(node)) return false
472
+ if (ts.isCallExpression(node) && isPureStaticCall(node)) return false
473
+ return isDynamic(node)
474
+ }
475
+
257
476
  function walk(node: ts.Node): void {
258
477
  if (ts.isJsxElement(node) && tryTemplateEmit(node)) return
259
478
  if (ts.isJsxSelfClosingElement(node) || ts.isJsxElement(node)) checkForWarnings(node)
@@ -296,6 +515,7 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
296
515
  const runtimeDomImports = ['_tpl']
297
516
  if (needsBindDirectImportGlobal) runtimeDomImports.push('_bindDirect')
298
517
  if (needsBindTextImportGlobal) runtimeDomImports.push('_bindText')
518
+ if (needsApplyPropsImportGlobal) runtimeDomImports.push('_applyProps')
299
519
  const reactivityImports = needsBindImportGlobal
300
520
  ? `\nimport { _bind } from "@pyreon/reactivity";`
301
521
  : ''
@@ -313,10 +533,19 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
313
533
 
314
534
  // ── Template emission helpers (closures over sf, code) ──────────────────────
315
535
 
316
- /** Check if a single attribute would prevent template emission. */
317
- function hasBailAttr(node: ts.JsxElement | ts.JsxSelfClosingElement): boolean {
536
+ /**
537
+ * Check if attributes prevent template emission.
538
+ * - `key` always bails (VNode reconciliation prop)
539
+ * - Spread on inner elements bails (too complex to merge in _bind)
540
+ * - Spread on root element is allowed — applied via applyProps in _bind
541
+ */
542
+ function hasBailAttr(node: ts.JsxElement | ts.JsxSelfClosingElement, isRoot = false): boolean {
318
543
  for (const attr of jsxAttrs(node)) {
319
- if (ts.isJsxSpreadAttribute(attr)) return true
544
+ if (ts.isJsxSpreadAttribute(attr)) {
545
+ // Allow spread on root element — handled in buildTemplateCall
546
+ if (isRoot) continue
547
+ return true
548
+ }
320
549
  if (ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name) && attr.name.text === 'key')
321
550
  return true
322
551
  }
@@ -343,10 +572,13 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
343
572
  * Count DOM elements in a JSX subtree. Returns -1 if the tree is not
344
573
  * eligible for template emission.
345
574
  */
346
- function templateElementCount(node: ts.JsxElement | ts.JsxSelfClosingElement): number {
575
+ function templateElementCount(
576
+ node: ts.JsxElement | ts.JsxSelfClosingElement,
577
+ isRoot = false,
578
+ ): number {
347
579
  const tag = jsxTagName(node)
348
580
  if (!tag || !isLowerCase(tag)) return -1
349
- if (hasBailAttr(node)) return -1
581
+ if (hasBailAttr(node, isRoot)) return -1
350
582
  if (!ts.isJsxElement(node)) return 1
351
583
 
352
584
  let count = 1
@@ -382,6 +614,7 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
382
614
  const reactiveBindExprs: string[] = []
383
615
  let needsBindTextImport = false
384
616
  let needsBindDirectImport = false
617
+ let needsApplyPropsImport = false
385
618
 
386
619
  function nextVar(): string {
387
620
  return `__e${varIdx++}`
@@ -469,7 +702,7 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
469
702
  if (ts.isArrowFunction(exprNode) || ts.isFunctionExpression(exprNode)) {
470
703
  return { expr: `(${sliceExpr(exprNode)})()`, isReactive: true }
471
704
  }
472
- return { expr: sliceExpr(exprNode), isReactive: containsCall(exprNode) }
705
+ return { expr: sliceExpr(exprNode), isReactive: isDynamic(exprNode) }
473
706
  }
474
707
 
475
708
  /** Build a setter expression for an attribute. */
@@ -559,6 +792,18 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
559
792
 
560
793
  /** Process a single attribute, returning HTML to append. */
561
794
  function processOneAttr(attr: ts.JsxAttributeLike, varName: string): string {
795
+ // Spread attribute: apply all props at runtime
796
+ if (ts.isJsxSpreadAttribute(attr)) {
797
+ const expr = sliceExpr(attr.expression)
798
+ // Use runtime-dom's applyProps which handles class, style, events, etc.
799
+ needsApplyPropsImport = true
800
+ if (isDynamic(attr.expression)) {
801
+ reactiveBindExprs.push(`_applyProps(${varName}, ${expr})`)
802
+ } else {
803
+ bindLines.push(`_applyProps(${varName}, ${expr})`)
804
+ }
805
+ return ''
806
+ }
562
807
  if (!ts.isJsxAttribute(attr)) return ''
563
808
  const attrName = ts.isIdentifier(attr.name) ? attr.name.text : ''
564
809
  if (attrName === 'key') return ''
@@ -598,8 +843,11 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
598
843
  const d = nextDisp()
599
844
  bindLines.push(`const ${d} = _bindText(${directRef}, ${tVar})`)
600
845
  } else {
601
- // Collected into the combined _bind at the end
602
- reactiveBindExprs.push(`${tVar}.data = ${expr}`)
846
+ // Each reactive text child gets its own _bind independent tracking.
847
+ // When r.name() changes, r.email()'s _bind doesn't re-run.
848
+ needsBindImportGlobal = true
849
+ const d = nextDisp()
850
+ bindLines.push(`const ${d} = _bind(() => { ${tVar}.data = ${inlineVarsInText(expr)} })`)
603
851
  }
604
852
  return needsPlaceholder ? '<!>' : ''
605
853
  }
@@ -709,6 +957,7 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
709
957
 
710
958
  if (needsBindTextImport) needsBindTextImportGlobal = true
711
959
  if (needsBindDirectImport) needsBindDirectImportGlobal = true
960
+ if (needsApplyPropsImport) needsApplyPropsImportGlobal = true
712
961
 
713
962
  // Build bind function body
714
963
  const escaped = html.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
@@ -722,7 +971,7 @@ export function transformJSX(code: string, filename = 'input.tsx'): TransformRes
722
971
  if (reactiveBindExprs.length > 0) {
723
972
  needsBindImportGlobal = true
724
973
  const combinedName = nextDisp()
725
- const combinedBody = reactiveBindExprs.join('; ')
974
+ const combinedBody = reactiveBindExprs.map(inlineVarsInText).join('; ')
726
975
  bindLines.push(`const ${combinedName} = _bind(() => { ${combinedBody} })`)
727
976
  }
728
977
 
@@ -933,22 +1182,47 @@ function isStatic(node: ts.Expression): boolean {
933
1182
  node.kind === ts.SyntaxKind.NullKeyword ||
934
1183
  node.kind === ts.SyntaxKind.UndefinedKeyword
935
1184
  )
1185
+ // Note: object/array literals are NOT static — they need runtime application
1186
+ // (e.g., style={{ color: "red" }} requires Object.assign at runtime).
936
1187
  }
937
1188
 
938
- function shouldWrap(node: ts.Expression): boolean {
939
- // Already a function — user explicitly wrapped or it's a callback
940
- if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return false
941
- // Static literal no signals involved
942
- if (isStatic(node)) return false
943
- // Only wrap if the expression tree contains a call — signal reads are always
944
- // function calls (e.g. `count()`, `name()`). Plain identifiers, object literals
945
- // like `style={{ color: "red" }}`, array literals, and member accesses are
946
- // left as-is to avoid unnecessary reactive wrappers.
947
- return containsCall(node)
1189
+ /** Known pure global functions that don't read signals. */
1190
+ const PURE_CALLS = new Set([
1191
+ 'Math.max', 'Math.min', 'Math.abs', 'Math.floor', 'Math.ceil', 'Math.round',
1192
+ 'Math.pow', 'Math.sqrt', 'Math.random', 'Math.trunc', 'Math.sign',
1193
+ 'Number.parseInt', 'Number.parseFloat', 'Number.isNaN', 'Number.isFinite',
1194
+ 'parseInt', 'parseFloat', 'isNaN', 'isFinite',
1195
+ 'String.fromCharCode', 'String.fromCodePoint',
1196
+ 'Object.keys', 'Object.values', 'Object.entries', 'Object.assign',
1197
+ 'Object.freeze', 'Object.create',
1198
+ 'Array.from', 'Array.isArray', 'Array.of',
1199
+ 'JSON.stringify', 'JSON.parse',
1200
+ 'encodeURIComponent', 'decodeURIComponent', 'encodeURI', 'decodeURI',
1201
+ 'Date.now',
1202
+ ])
1203
+
1204
+ /** Check if a call expression calls a known pure function with static args. */
1205
+ function isPureStaticCall(node: ts.CallExpression): boolean {
1206
+ const callee = node.expression
1207
+ let name = ''
1208
+
1209
+ if (ts.isIdentifier(callee)) {
1210
+ name = callee.text
1211
+ } else if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)) {
1212
+ name = `${callee.expression.text}.${callee.name.text}`
1213
+ }
1214
+
1215
+ if (!PURE_CALLS.has(name)) return false
1216
+ // Pure call with all static arguments → result is static
1217
+ return node.arguments.every((arg) => !ts.isSpreadElement(arg) && isStatic(arg))
948
1218
  }
949
1219
 
950
1220
  function containsCall(node: ts.Node): boolean {
951
- if (ts.isCallExpression(node)) return true
1221
+ if (ts.isCallExpression(node)) {
1222
+ // Skip pure calls with static args
1223
+ if (isPureStaticCall(node as ts.CallExpression)) return false
1224
+ return true
1225
+ }
952
1226
  if (ts.isTaggedTemplateExpression(node)) return true
953
1227
  // Don't recurse into nested functions — they're self-contained
954
1228
  if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return false
@@ -528,8 +528,14 @@ describe('JSX transform — template emission', () => {
528
528
  expect(result).not.toContain('_tpl(')
529
529
  })
530
530
 
531
- test('does NOT emit _tpl for spread attributes', () => {
531
+ test('emits _tpl for root spread with _applyProps in bind', () => {
532
532
  const result = t('<div {...props}><span /></div>')
533
+ expect(result).toContain('_tpl(')
534
+ expect(result).toContain('_applyProps(__root, props)')
535
+ })
536
+
537
+ test('does NOT emit _tpl for spread on inner elements', () => {
538
+ const result = t('<div><span {...innerProps} /></div>')
533
539
  expect(result).not.toContain('_tpl(')
534
540
  })
535
541
 
@@ -1174,3 +1180,188 @@ describe('JSX transform — style attribute in templates', () => {
1174
1180
  expect(result).toContain('style.cssText')
1175
1181
  })
1176
1182
  })
1183
+
1184
+ // ─── Pure call detection ────────────────────────────────────────────────────
1185
+
1186
+ describe('JSX transform — pure call detection', () => {
1187
+ test('Math.max with static args is not wrapped', () => {
1188
+ const result = t('<div>{Math.max(5, 10)}</div>')
1189
+ expect(result).not.toContain('() =>')
1190
+ })
1191
+
1192
+ test('JSON.stringify with string arg is not wrapped', () => {
1193
+ const result = t('<div>{JSON.stringify("hello")}</div>')
1194
+ expect(result).not.toContain('() =>')
1195
+ })
1196
+
1197
+ test('JSON.stringify with object arg IS wrapped (object not static)', () => {
1198
+ const result = t('<div>{JSON.stringify({a: 1})}</div>')
1199
+ // Object literals are not considered static by the compiler
1200
+ expect(result).toContain('.data =')
1201
+ })
1202
+
1203
+ test('Math.max with dynamic arg (signal call) IS wrapped', () => {
1204
+ const result = t('<div>{Math.max(count(), 10)}</div>')
1205
+ // Dynamic argument means the result depends on a signal
1206
+ expect(result).toContain('Math.max(count(), 10)')
1207
+ expect(result).toContain('.data =')
1208
+ })
1209
+
1210
+ test('unknown function call IS wrapped', () => {
1211
+ const result = t('<div>{unknownFn(5)}</div>')
1212
+ // Unknown function is not in PURE_CALLS, so it gets wrapped
1213
+ expect(result).toContain('.data =')
1214
+ })
1215
+
1216
+ test('Math.floor with static arg is not wrapped', () => {
1217
+ const result = t('<div>{Math.floor(3.14)}</div>')
1218
+ expect(result).not.toContain('() =>')
1219
+ })
1220
+
1221
+ test('Number.parseInt with static arg is not wrapped', () => {
1222
+ const result = t('<div>{Number.parseInt("42", 10)}</div>')
1223
+ expect(result).not.toContain('() =>')
1224
+ })
1225
+ })
1226
+
1227
+ // ─── Per-text-node bind (separate bindings) ─────────────────────────────────
1228
+
1229
+ describe('JSX transform — per-text-node bind', () => {
1230
+ test('two adjacent signal calls produce two separate _bindText calls', () => {
1231
+ const result = t('<div>{a()}{b()}</div>')
1232
+ expect(result).toContain('_bindText(a,')
1233
+ expect(result).toContain('_bindText(b,')
1234
+ })
1235
+
1236
+ test('two signal expressions with text between produce separate bindings', () => {
1237
+ const result = t('<div>{a()} and {b()}</div>')
1238
+ expect(result).toContain('_bindText(a,')
1239
+ expect(result).toContain('_bindText(b,')
1240
+ })
1241
+
1242
+ test('three signal calls produce three separate _bindText calls', () => {
1243
+ const result = t('<div>{a()}{b()}{c()}</div>')
1244
+ expect(result).toContain('_bindText(a,')
1245
+ expect(result).toContain('_bindText(b,')
1246
+ expect(result).toContain('_bindText(c,')
1247
+ })
1248
+ })
1249
+
1250
+ // ─── Reactive props auto-detection ──────────────────────────────────────────
1251
+
1252
+ describe('JSX transform — reactive props detection', () => {
1253
+ test('props.x in text child is reactive (wrapped in _bind)', () => {
1254
+ const result = t('function Comp(props) { return <div>{props.name}</div> }')
1255
+ expect(result).toContain('_bind(() => {')
1256
+ expect(result).toContain('props.name')
1257
+ })
1258
+
1259
+ test('props.x in attribute is reactive (wrapped in _bind)', () => {
1260
+ const result = t('function Comp(props) { return <div class={props.cls}></div> }')
1261
+ expect(result).toContain('_bind(() => {')
1262
+ expect(result).toContain('props.cls')
1263
+ })
1264
+
1265
+ test('prop-derived variable inlined in text child', () => {
1266
+ const result = t('function Comp(props) { const x = props.name ?? "anon"; return <div>{x}</div> }')
1267
+ expect(result).toContain('_bind(() => {')
1268
+ expect(result).toContain('props.name ?? "anon"')
1269
+ // x should be inlined, not used directly
1270
+ expect(result).not.toMatch(/__t\d+\.data = x\b/)
1271
+ })
1272
+
1273
+ test('prop-derived variable inlined in attribute', () => {
1274
+ const result = t('function Comp(props) { const align = props.alignX ?? "left"; return <div class={align}></div> }')
1275
+ expect(result).toContain('_bind(() => {')
1276
+ expect(result).toContain('props.alignX ?? "left"')
1277
+ })
1278
+
1279
+ test('splitProps results tracked as props-like', () => {
1280
+ const result = t('function Comp(props) { const [own, rest] = splitProps(props, ["x"]); const v = own.x ?? 5; return <div>{v}</div> }')
1281
+ expect(result).toContain('_bind(() => {')
1282
+ expect(result).toContain('own.x ?? 5')
1283
+ })
1284
+
1285
+ test('non-component function NOT tracked (no JSX)', () => {
1286
+ const result = t('function helper(props) { const x = props.y; return x }')
1287
+ expect(result).not.toContain('_bind')
1288
+ expect(result).not.toContain('_tpl')
1289
+ })
1290
+
1291
+ test('static values unchanged by props tracking', () => {
1292
+ const result = t('function Comp(props) { return <div class="static">text</div> }')
1293
+ expect(result).toContain('_tpl("<div class=\\"static\\">text</div>"')
1294
+ expect(result).not.toContain('_bind')
1295
+ })
1296
+
1297
+ test('signal calls still work alongside props detection', () => {
1298
+ const result = t('function Comp(props) { return <div>{count()}</div> }')
1299
+ expect(result).toContain('_bindText(count,')
1300
+ })
1301
+
1302
+ test('arrow function component detected', () => {
1303
+ const result = t('const Comp = (props) => <div>{props.x}</div>')
1304
+ expect(result).toContain('_bind(() => {')
1305
+ expect(result).toContain('props.x')
1306
+ })
1307
+ })
1308
+
1309
+ // ─── Transitive prop derivation ─────────────────────────────────────────────
1310
+
1311
+ describe('JSX transform — transitive prop derivation', () => {
1312
+ test('const b = a + 1 where a is prop-derived', () => {
1313
+ const result = t('function Comp(props) { const a = props.x; const b = a + 1; return <div>{b}</div> }')
1314
+ expect(result).toContain('_bind(() => {')
1315
+ expect(result).toContain('props.x')
1316
+ // b should be inlined transitively
1317
+ expect(result).not.toMatch(/__t\d+\.data = b\b/)
1318
+ })
1319
+
1320
+ test('deep chain: c = b * 2, b = a + 1, a = props.x', () => {
1321
+ const result = t('function Comp(props) { const a = props.x; const b = a + 1; const c = b * 2; return <div>{c}</div> }')
1322
+ expect(result).toContain('props.x')
1323
+ // Full chain inlined
1324
+ expect(result).toContain('_bind')
1325
+ })
1326
+
1327
+ test('non-prop-derived variable NOT inlined', () => {
1328
+ const result = t('function Comp(props) { const x = 42; return <div>{x}</div> }')
1329
+ // x = 42 is static, not prop-derived — should NOT be wrapped
1330
+ expect(result).not.toContain('_bind')
1331
+ })
1332
+
1333
+ test('let variables NOT tracked (mutable — can be reassigned)', () => {
1334
+ const result = t('function Comp(props) { let x = props.y; x = "override"; return <div>{x}</div> }')
1335
+ // let is excluded — x is NOT inlined, set statically
1336
+ expect(result).toContain('textContent = x')
1337
+ expect(result).not.toContain('_bind')
1338
+ })
1339
+
1340
+ test('mixed props and signals in same expression', () => {
1341
+ const result = t('function Comp(props) { return <div class={`${props.base} ${count()}`}></div> }')
1342
+ expect(result).toContain('_bind(() => {')
1343
+ })
1344
+
1345
+ test('prop-derived used in non-JSX stays static', () => {
1346
+ // The variable is still captured — only JSX usage is inlined
1347
+ const result = t('function Comp(props) { const x = props.y; console.log(x); return <div>{x}</div> }')
1348
+ // console.log(x) uses the captured value — compiler doesn't touch it
1349
+ expect(result).toContain('console.log(x)')
1350
+ // JSX usage is inlined
1351
+ expect(result).toContain('props.y')
1352
+ expect(result).toContain('_bind')
1353
+ })
1354
+
1355
+ test('.map() callback params NOT treated as props', () => {
1356
+ const result = t('function App(props) { return <div>{tabs.map((tab) => { const C = tab.component; return <div><C /></div> })}</div> }')
1357
+ // tab is a callback param, not a component's props — should NOT be tracked
1358
+ expect(result).not.toContain('(tab.component)')
1359
+ })
1360
+
1361
+ test('prop read with ?? default used multiple times', () => {
1362
+ const result = t('function Comp(props) { const x = props.a ?? "def"; return <div class={x}>{x}</div> }')
1363
+ // Both uses should be inlined
1364
+ const matches = result.match(/props\.a \?\? "def"/g)
1365
+ expect(matches?.length).toBeGreaterThanOrEqual(2)
1366
+ })
1367
+ })