@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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +241 -15
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/jsx.ts +296 -22
- package/src/tests/jsx.test.ts +192 -1
package/package.json
CHANGED
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
|
-
|
|
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
|
-
/**
|
|
317
|
-
|
|
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))
|
|
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(
|
|
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:
|
|
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
|
-
//
|
|
602
|
-
|
|
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
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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))
|
|
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
|
package/src/tests/jsx.test.ts
CHANGED
|
@@ -528,8 +528,14 @@ describe('JSX transform — template emission', () => {
|
|
|
528
528
|
expect(result).not.toContain('_tpl(')
|
|
529
529
|
})
|
|
530
530
|
|
|
531
|
-
test('
|
|
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
|
+
})
|