@pyreon/compiler 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/jsx.ts CHANGED
@@ -17,23 +17,42 @@
17
17
  * values, and all children are text nodes or other static JSX nodes.
18
18
  *
19
19
  * Template emission:
20
- * - JSX element trees with ≥ 2 DOM elements (no components, no spread attrs)
21
- * are compiled to `_tpl(html, bindFn)` calls instead of nested `h()` calls.
20
+ * - JSX element trees with ≥ 1 DOM elements (no components, no spread attrs on
21
+ * inner elements) are compiled to `_tpl(html, bindFn)` calls instead of nested
22
+ * `h()` calls.
22
23
  * - The HTML string is parsed once via <template>.innerHTML, then cloneNode(true)
23
24
  * for each instance (~5-10x faster than sequential createElement calls).
24
25
  * - Static attributes are baked into the HTML string; dynamic attributes and
25
26
  * text content use renderEffect in the bind function.
26
27
  *
27
- * Implementation: TypeScript parser for positions + magic-string replacements.
28
- * No extra runtime dependencies — `typescript` is already in devDependencies.
29
- *
30
- * Known limitation (v0): expressions inside *nested* JSX within a child
31
- * expression container are not individually wrapped. They are still reactive
32
- * because the outer wrapper re-evaluates the whole subtree, just at a coarser
33
- * granularity. Fine-grained nested wrapping is planned for a future pass.
28
+ * Implementation: Rust native binary (napi-rs) when available, JS fallback via oxc-parser.
34
29
  */
35
30
 
36
- import ts from 'typescript'
31
+ import { parseSync } from 'oxc-parser'
32
+ import { createRequire } from 'node:module'
33
+ import { fileURLToPath } from 'node:url'
34
+ import { dirname, join } from 'node:path'
35
+
36
+ // ─── Native binary auto-detection ────────────────────────────────────────────
37
+ // Try to load the Rust napi-rs binary for 3.7-8.2x faster transforms.
38
+ // Falls back to the JS implementation below if the binary isn't available
39
+ // (wrong platform, CI environment, WASM runtime like StackBlitz, etc.)
40
+ //
41
+ // Uses createRequire for ESM compatibility — __dirname and require() don't
42
+ // exist in ESM modules.
43
+ type NativeTransformFn = (code: string, filename: string, ssr: boolean, knownSignals: string[] | null) => TransformResult
44
+ let nativeTransformJsx: NativeTransformFn | null = null
45
+
46
+ try {
47
+ const __filename = fileURLToPath(import.meta.url)
48
+ const __dirname = dirname(__filename)
49
+ const nativeRequire = createRequire(import.meta.url)
50
+ const nativePath = join(__dirname, '..', 'native', 'pyreon-compiler.node')
51
+ const native = nativeRequire(nativePath) as { transformJsx: NativeTransformFn }
52
+ nativeTransformJsx = native.transformJsx
53
+ } catch {
54
+ // Native binary not available — JS fallback will be used
55
+ }
37
56
 
38
57
  export interface CompilerWarning {
39
58
  /** Warning message */
@@ -65,66 +84,196 @@ const SKIP_PROPS = new Set(['key', 'ref'])
65
84
  const EVENT_RE = /^on[A-Z]/
66
85
  // Events delegated to the container — must match runtime DELEGATED_EVENTS set
67
86
  const DELEGATED_EVENTS = new Set([
68
- 'click',
69
- 'dblclick',
70
- 'contextmenu',
71
- 'focusin',
72
- 'focusout',
73
- 'input',
74
- 'change',
75
- 'keydown',
76
- 'keyup',
77
- 'mousedown',
78
- 'mouseup',
79
- 'mousemove',
80
- 'mouseover',
81
- 'mouseout',
82
- 'pointerdown',
83
- 'pointerup',
84
- 'pointermove',
85
- 'pointerover',
86
- 'pointerout',
87
- 'touchstart',
88
- 'touchend',
89
- 'touchmove',
87
+ 'click', 'dblclick', 'contextmenu', 'focusin', 'focusout', 'input',
88
+ 'change', 'keydown', 'keyup', 'mousedown', 'mouseup', 'mousemove',
89
+ 'mouseover', 'mouseout', 'pointerdown', 'pointerup', 'pointermove',
90
+ 'pointerover', 'pointerout', 'touchstart', 'touchend', 'touchmove',
90
91
  'submit',
91
92
  ])
92
93
 
93
94
  export interface TransformOptions {
94
95
  /**
95
96
  * Compile for server-side rendering. When true, the compiler skips the
96
- * `_tpl()` template optimization (which mutates a real DOM via
97
- * `document.createElement` etc.) and falls back to plain `h()` calls so
98
- * `@pyreon/runtime-server` can walk the VNode tree. Client builds keep
99
- * the `_tpl()` fast path. Default: false.
97
+ * `_tpl()` template optimization and falls back to plain `h()` calls so
98
+ * `@pyreon/runtime-server` can walk the VNode tree. Default: false.
100
99
  */
101
100
  ssr?: boolean
101
+
102
+ /**
103
+ * Known signal variable names from resolved imports.
104
+ * The Vite plugin maintains a cross-module signal export registry and
105
+ * passes imported signal names here so the compiler can auto-call them
106
+ * in JSX even though the `signal()` declaration is in another file.
107
+ *
108
+ * @example
109
+ * // store.ts: export const count = signal(0)
110
+ * // component.tsx: import { count } from './store'
111
+ * transformJSX(code, 'component.tsx', { knownSignals: ['count'] })
112
+ * // {count} in JSX → {() => count()}
113
+ */
114
+ knownSignals?: string[]
102
115
  }
103
116
 
117
+ // ─── oxc ESTree helpers ───────────────────────────────────────────────────────
118
+
119
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
120
+ type N = any // ESTree node — untyped for speed, matches the lint package approach
121
+
122
+ function getLang(filename: string): 'tsx' | 'jsx' {
123
+ if (filename.endsWith('.jsx')) return 'jsx'
124
+ // Default to tsx so JSX is always parsed — matches the original TypeScript
125
+ // parser behavior which forced ScriptKind.TSX for all files.
126
+ return 'tsx'
127
+ }
128
+
129
+ /** Binary search for line/column from byte offset. */
130
+ function makeLineIndex(code: string): (offset: number) => { line: number; column: number } {
131
+ const lineStarts = [0]
132
+ for (let i = 0; i < code.length; i++) {
133
+ if (code[i] === '\n') lineStarts.push(i + 1)
134
+ }
135
+ return (offset: number) => {
136
+ let lo = 0
137
+ let hi = lineStarts.length - 1
138
+ while (lo <= hi) {
139
+ const mid = (lo + hi) >>> 1
140
+ if (lineStarts[mid]! <= offset) lo = mid + 1
141
+ else hi = mid - 1
142
+ }
143
+ return { line: lo, column: offset - lineStarts[lo - 1]! }
144
+ }
145
+ }
146
+
147
+ /** Iterate all direct children of an ESTree node via known property keys. */
148
+ function forEachChild(node: N, cb: (child: N) => void): void {
149
+ if (!node || typeof node !== 'object') return
150
+ const keys = Object.keys(node)
151
+ for (let i = 0; i < keys.length; i++) {
152
+ const key = keys[i]!
153
+ // Skip metadata fields for speed
154
+ if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue
155
+ const val = node[key]
156
+ if (Array.isArray(val)) {
157
+ for (let j = 0; j < val.length; j++) {
158
+ const item = val[j]
159
+ if (item && typeof item === 'object' && item.type) cb(item)
160
+ }
161
+ } else if (val && typeof val === 'object' && val.type) {
162
+ cb(val)
163
+ }
164
+ }
165
+ }
166
+
167
+ // ─── JSX element helpers ────────────────────────────────────────────────────
168
+
169
+ function jsxTagName(node: N): string {
170
+ const opening = node.openingElement
171
+ if (!opening) return ''
172
+ const name = opening.name
173
+ return name?.type === 'JSXIdentifier' ? name.name : ''
174
+ }
175
+
176
+ function isSelfClosing(node: N): boolean {
177
+ return node.type === 'JSXElement' && node.openingElement?.selfClosing === true
178
+ }
179
+
180
+ function jsxAttrs(node: N): N[] {
181
+ return node.openingElement?.attributes ?? []
182
+ }
183
+
184
+ function jsxChildren(node: N): N[] {
185
+ return node.children ?? []
186
+ }
187
+
188
+ // ─── Main transform ─────────────────────────────────────────────────────────
189
+
104
190
  export function transformJSX(
105
191
  code: string,
106
192
  filename = 'input.tsx',
107
193
  options: TransformOptions = {},
194
+ ): TransformResult {
195
+ // Try Rust native binary first (3.7-8.2x faster).
196
+ // Per-call try/catch: if the native binary panics on an edge case
197
+ // (bad UTF-8, unexpected AST shape), fall back gracefully instead
198
+ // of crashing the Vite dev server.
199
+ if (nativeTransformJsx) {
200
+ try {
201
+ return nativeTransformJsx(code, filename, options.ssr === true, options.knownSignals ?? null)
202
+ } catch {
203
+ // Native transform failed — fall through to JS implementation
204
+ }
205
+ }
206
+ return transformJSX_JS(code, filename, options)
207
+ }
208
+
209
+ /** JS fallback implementation — used when the native binary isn't available. */
210
+ export function transformJSX_JS(
211
+ code: string,
212
+ filename = 'input.tsx',
213
+ options: TransformOptions = {},
108
214
  ): TransformResult {
109
215
  const ssr = options.ssr === true
110
- const scriptKind =
111
- filename.endsWith('.tsx') || filename.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TSX // default to TSX so JSX is always parsed
112
216
 
113
- const sf = ts.createSourceFile(
114
- filename,
115
- code,
116
- ts.ScriptTarget.ESNext,
117
- /* setParentNodes */ true,
118
- scriptKind,
119
- )
217
+ let program: N
218
+ try {
219
+ const result = parseSync(filename, code, {
220
+ sourceType: 'module',
221
+ lang: getLang(filename),
222
+ })
223
+ program = result.program
224
+ } catch {
225
+ return { code, warnings: [] }
226
+ }
227
+
228
+ const locate = makeLineIndex(code)
120
229
 
121
230
  type Replacement = { start: number; end: number; text: string }
122
231
  const replacements: Replacement[] = []
123
232
  const warnings: CompilerWarning[] = []
124
233
 
125
- function warn(node: ts.Node, message: string, warnCode: CompilerWarning['code']): void {
126
- const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
127
- warnings.push({ message, line: line + 1, column: character, code: warnCode })
234
+ function warn(node: N, message: string, warnCode: CompilerWarning['code']): void {
235
+ const { line, column } = locate(node.start as number)
236
+ warnings.push({ message, line, column, code: warnCode })
237
+ }
238
+
239
+ // ── Parent + children maps (built once, eliminates repeated Object.keys) ──
240
+ const parentMap = new WeakMap<object, N>()
241
+ const childrenMap = new WeakMap<object, N[]>()
242
+
243
+ /** Build parent pointers + cached children arrays for the entire AST. */
244
+ function buildMaps(node: N): void {
245
+ const kids: N[] = []
246
+ const keys = Object.keys(node)
247
+ for (let i = 0; i < keys.length; i++) {
248
+ const key = keys[i]!
249
+ if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue
250
+ const val = node[key]
251
+ if (Array.isArray(val)) {
252
+ for (let j = 0; j < val.length; j++) {
253
+ const item = val[j]
254
+ if (item && typeof item === 'object' && item.type) kids.push(item)
255
+ }
256
+ } else if (val && typeof val === 'object' && val.type) {
257
+ kids.push(val)
258
+ }
259
+ }
260
+ childrenMap.set(node, kids)
261
+ for (let i = 0; i < kids.length; i++) {
262
+ parentMap.set(kids[i]!, node)
263
+ buildMaps(kids[i]!)
264
+ }
265
+ }
266
+ buildMaps(program)
267
+
268
+ function findParent(node: N): N | undefined {
269
+ return parentMap.get(node)
270
+ }
271
+
272
+ /** Fast child iteration using pre-computed children array. */
273
+ function forEachChildFast(node: N, cb: (child: N) => void): void {
274
+ const kids = childrenMap.get(node)
275
+ if (!kids) return
276
+ for (let i = 0; i < kids.length; i++) cb(kids[i]!)
128
277
  }
129
278
 
130
279
  // ── Static hoisting state ─────────────────────────────────────────────────
@@ -139,444 +288,491 @@ export function transformJSX(
139
288
  let needsApplyPropsImportGlobal = false
140
289
  let needsMountSlotImportGlobal = false
141
290
 
142
- /**
143
- * If `node` is a fully-static JSX element/fragment, register a module-scope
144
- * hoist for it and return the generated variable name. Otherwise return null.
145
- */
146
- function maybeHoist(node: ts.Node): string | null {
291
+ function maybeHoist(node: N): string | null {
147
292
  if (
148
- (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) &&
149
- isStaticJSXNode(node as ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment)
293
+ (node.type === 'JSXElement' || node.type === 'JSXFragment') &&
294
+ isStaticJSXNode(node)
150
295
  ) {
151
296
  const name = `_$h${hoistIdx++}`
152
- const text = code.slice(node.getStart(sf), node.getEnd())
297
+ const text = code.slice(node.start as number, node.end as number)
153
298
  hoists.push({ name, text })
154
299
  return name
155
300
  }
156
301
  return null
157
302
  }
158
303
 
159
- function wrap(expr: ts.Expression): void {
160
- const start = expr.getStart(sf)
161
- const end = expr.getEnd()
162
- // Object literals need parens: `() => { ... }` is a function body with
163
- // labeled statements, not an object expression. Use `() => ({ ... })`.
304
+ function wrap(expr: N): void {
305
+ const start = expr.start as number
306
+ const end = expr.end as number
164
307
  const sliced = sliceExpr(expr)
165
- const text = ts.isObjectLiteralExpression(expr)
308
+ const text = expr.type === 'ObjectExpression'
166
309
  ? `() => (${sliced})`
167
310
  : `() => ${sliced}`
168
311
  replacements.push({ start, end, text })
169
312
  }
170
313
 
171
- /** Try to hoist or wrap an expression, pushing a replacement if needed. */
172
- function hoistOrWrap(expr: ts.Expression): void {
314
+ function hoistOrWrap(expr: N): void {
173
315
  const hoistName = maybeHoist(expr)
174
316
  if (hoistName) {
175
- replacements.push({ start: expr.getStart(sf), end: expr.getEnd(), text: hoistName })
317
+ replacements.push({ start: expr.start as number, end: expr.end as number, text: hoistName })
176
318
  } else if (shouldWrap(expr)) {
177
319
  wrap(expr)
178
320
  }
179
321
  }
180
322
 
181
- // ── walk sub-handlers ───────────────────────────────────────────────────────
323
+ // ── Template emit ─────────────────────────────────────────────────────────
182
324
 
183
- /** Try to emit a template for a JsxElement. Returns true if handled. */
184
- function tryTemplateEmit(node: ts.JsxElement): boolean {
185
- // SSR builds skip the `_tpl()` fast path entirely. `_tpl` clones a real
186
- // DOM element via `document.createElement('template')` and the emitted
187
- // bind callback calls `appendChild`, `createTextNode`, etc. — none of
188
- // that exists in Node. Falling back to standard JSX→`h()` lets
189
- // `@pyreon/runtime-server` walk the VNode tree to a string. Client
190
- // builds keep the template optimization.
325
+ function tryTemplateEmit(node: N): boolean {
191
326
  if (ssr) return false
192
- const elemCount = templateElementCount(node, /* isRoot */ true)
327
+ if (isSelfClosing(node)) return false
328
+ const elemCount = templateElementCount(node, true)
193
329
  if (elemCount < 1) return false
194
330
  const tplCall = buildTemplateCall(node)
195
331
  if (!tplCall) return false
196
- const start = node.getStart(sf)
197
- const end = node.getEnd()
198
- const parent = node.parent
199
- const needsBraces = parent && (ts.isJsxElement(parent) || ts.isJsxFragment(parent))
332
+ const start = node.start as number
333
+ const end = node.end as number
334
+ const parent = findParent(node)
335
+ const needsBraces = parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment')
200
336
  replacements.push({ start, end, text: needsBraces ? `{${tplCall}}` : tplCall })
201
337
  needsTplImport = true
202
338
  return true
203
339
  }
204
340
 
205
- /** Emit warnings for common JSX mistakes (e.g. <For> without by). */
206
- function checkForWarnings(node: ts.JsxElement | ts.JsxSelfClosingElement): void {
207
- const opening = ts.isJsxElement(node) ? node.openingElement : node
208
- const tagName = ts.isIdentifier(opening.tagName) ? opening.tagName.text : ''
341
+ function checkForWarnings(node: N): void {
342
+ const tagName = jsxTagName(node)
209
343
  if (tagName !== 'For') return
210
- const hasBy = opening.attributes.properties.some(
211
- (p) => ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text === 'by',
344
+ const hasBy = jsxAttrs(node).some(
345
+ (p: N) => p.type === 'JSXAttribute' && p.name?.type === 'JSXIdentifier' && p.name.name === 'by',
212
346
  )
213
347
  if (!hasBy) {
214
348
  warn(
215
- opening.tagName,
349
+ node.openingElement?.name ?? node,
216
350
  `<For> without a "by" prop will use index-based diffing, which is slower and may cause bugs with stateful children. Add by={(item) => item.id} for efficient keyed reconciliation.`,
217
351
  'missing-key-on-for',
218
352
  )
219
353
  }
220
354
  }
221
355
 
222
- /** Handle a JSX attribute node wrap or hoist its value if needed.
223
- *
224
- * Both DOM and component props are processed:
225
- * - DOM props: () => expr — applyProp creates renderEffect
226
- * - Component props: _rp(() => expr) — makeReactiveProps converts to getters
227
- *
228
- * The _rp() brand distinguishes compiler wrappers from user-written accessor
229
- * props (like Show's when, For's each) so makeReactiveProps only converts
230
- * compiler-emitted wrappers.
231
- */
232
- function handleJsxAttribute(node: ts.JsxAttribute): void {
233
- const name = ts.isIdentifier(node.name) ? node.name.text : ''
356
+ function handleJsxAttribute(node: N, parentElement: N): void {
357
+ const name = node.name?.type === 'JSXIdentifier' ? node.name.name : ''
234
358
  if (SKIP_PROPS.has(name) || EVENT_RE.test(name)) return
235
- if (!node.initializer || !ts.isJsxExpression(node.initializer)) return
236
- const expr = node.initializer.expression
237
- if (!expr) return
359
+ if (!node.value || node.value.type !== 'JSXExpressionContainer') return
360
+ const expr = node.value.expression
361
+ if (!expr || expr.type === 'JSXEmptyExpression') return
238
362
 
239
- const openingEl = node.parent.parent as ts.JsxOpeningElement | ts.JsxSelfClosingElement
240
- const tagName = ts.isIdentifier(openingEl.tagName) ? openingEl.tagName.text : ''
363
+ const tagName = jsxTagName(parentElement)
241
364
  const isComponent = tagName.length > 0 && tagName.charAt(0) !== tagName.charAt(0).toLowerCase()
242
365
 
243
366
  if (isComponent) {
244
- // Component prop: wrap with _rp() brand so makeReactiveProps recognizes it.
245
- //
246
- // EXCEPTION: If the expression is a single JSX element (not a conditional),
247
- // do NOT wrap the outer expression. The JSX element is created once (stable VNode).
248
- // Its own inner props will be wrapped individually via recursive walk().
249
- // This prevents remounting: <Icon name={x()} /> stays one Icon instance,
250
- // only its name prop updates reactively.
251
- const isSingleJsx = ts.isJsxElement(expr) || ts.isJsxSelfClosingElement(expr)
367
+ const isSingleJsx = expr.type === 'JSXElement' || expr.type === 'JSXFragment'
252
368
  if (isSingleJsx) {
253
- // Don't wrap — recurse into the JSX element's attributes instead
254
- ts.forEachChild(expr, walk)
369
+ walkNode(expr)
255
370
  return
256
371
  }
257
-
258
372
  const hoistName = maybeHoist(expr)
259
373
  if (hoistName) {
260
- replacements.push({ start: expr.getStart(sf), end: expr.getEnd(), text: hoistName })
374
+ replacements.push({ start: expr.start as number, end: expr.end as number, text: hoistName })
261
375
  } else if (shouldWrap(expr)) {
262
- const start = expr.getStart(sf)
263
- const end = expr.getEnd()
264
- // Object literals need parens to disambiguate from arrow function body
376
+ const start = expr.start as number
377
+ const end = expr.end as number
265
378
  const sliced = sliceExpr(expr)
266
- const inner = ts.isObjectLiteralExpression(expr) ? `(${sliced})` : sliced
379
+ const inner = expr.type === 'ObjectExpression' ? `(${sliced})` : sliced
267
380
  replacements.push({ start, end, text: `_rp(() => ${inner})` })
268
381
  needsRpImport = true
269
382
  }
270
383
  } else {
271
- // DOM prop: standard () => expr wrapping
272
384
  hoistOrWrap(expr)
273
385
  }
274
386
  }
275
387
 
276
- /** Handle a JSX expression in child position — wrap, hoist, or recurse. */
277
- function handleJsxExpression(node: ts.JsxExpression): void {
388
+ function handleJsxExpression(node: N): void {
278
389
  const expr = node.expression
279
- if (!expr) return
390
+ if (!expr || expr.type === 'JSXEmptyExpression') return
280
391
  const hoistName = maybeHoist(expr)
281
392
  if (hoistName) {
282
- replacements.push({ start: expr.getStart(sf), end: expr.getEnd(), text: hoistName })
393
+ replacements.push({ start: expr.start as number, end: expr.end as number, text: hoistName })
283
394
  return
284
395
  }
285
396
  if (shouldWrap(expr)) {
286
397
  wrap(expr)
287
398
  return
288
399
  }
289
- // Not hoisted, not wrapped (e.g., arrow function in For callback).
290
- // Recurse into the expression body to find nested JSX elements
291
- // that should be compiled to _tpl() calls.
292
- ts.forEachChild(expr, walk)
400
+ walkNode(expr)
293
401
  }
294
402
 
295
- // ── Prop-derived variable tracking ─────────────────────────────────────────
296
- // Pre-pass: find variables derived from props/splitProps results inside
297
- // component functions. These are inlined at JSX use sites so the compiler's
298
- // existing wrapping makes them reactive.
299
- //
300
- // Example:
301
- // const align = props.alignX ?? 'left'
302
- // return <div class={align}> ← inlined to: class={props.alignX ?? 'left'}
303
- // ← compiler wraps: class={() => props.alignX ?? 'left'}
304
-
305
- /** Names that refer to the props object or splitProps results. */
403
+ // ── Prop-derived variable tracking (collected during the single walk) ─────
306
404
  const propsNames = new Set<string>()
405
+ const propDerivedVars = new Map<string, { start: number; end: number }>()
406
+
407
+ // ── Signal variable tracking (for auto-call in JSX) ──────────────────────
408
+ // Tracks `const x = signal(...)` declarations. In JSX expressions, bare
409
+ // references to these identifiers are auto-called: `{x}` → `{x()}`.
410
+ // This makes signals look like plain JS variables in templates while
411
+ // maintaining fine-grained reactivity.
412
+ const signalVars = new Set<string>(options.knownSignals)
413
+
414
+ // ── Scope-aware signal shadowing ──────────────────────────────────────────
415
+ // When a function/block declares a variable with the same name as a signal
416
+ // (e.g. `const show = 'text'` shadowing module-scope `const show = signal(false)`),
417
+ // that name is NOT a signal within that scope. The shadowedSignals set tracks
418
+ // names that are currently shadowed by a closer non-signal declaration.
419
+ const shadowedSignals = new Set<string>()
420
+
421
+ /** Check if an identifier name is an active (non-shadowed) signal variable. */
422
+ function isActiveSignal(name: string): boolean {
423
+ return signalVars.has(name) && !shadowedSignals.has(name)
424
+ }
307
425
 
308
- /** Map of variable name AST node of the original expression.
309
- * Using AST nodes instead of text avoids all string manipulation edge cases. */
310
- const propDerivedVars = new Map<string, ts.Expression>()
311
-
312
- /** Check if an expression reads from a tracked props-like object. */
313
- function readsFromProps(node: ts.Node): boolean {
314
- if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
315
- return propsNames.has(node.expression.text)
426
+ /** Find variable declarations and parameters in a function that shadow signal names. */
427
+ function findShadowingNames(node: N): string[] {
428
+ const shadows: string[] = []
429
+ // Check function parameters
430
+ for (const param of node.params ?? []) {
431
+ if (param.type === 'Identifier' && signalVars.has(param.name)) {
432
+ shadows.push(param.name)
433
+ }
434
+ // Handle destructured parameters: ({ name }) => ...
435
+ if (param.type === 'ObjectPattern') {
436
+ for (const prop of param.properties ?? []) {
437
+ const val = prop.value ?? prop.key
438
+ if (val?.type === 'Identifier' && signalVars.has(val.name)) {
439
+ shadows.push(val.name)
440
+ }
441
+ }
442
+ }
443
+ // Handle array destructured parameters: ([a, b]) => ...
444
+ if (param.type === 'ArrayPattern') {
445
+ for (const el of param.elements ?? []) {
446
+ if (el?.type === 'Identifier' && signalVars.has(el.name)) {
447
+ shadows.push(el.name)
448
+ }
449
+ }
450
+ }
316
451
  }
317
- if (ts.isElementAccessExpression(node) && ts.isIdentifier(node.expression)) {
318
- return propsNames.has(node.expression.text)
452
+ // Check top-level variable declarations in the function body
453
+ const body = node.body
454
+ const stmts = body?.body ?? body?.statements
455
+ if (!Array.isArray(stmts)) return shadows
456
+ for (const stmt of stmts) {
457
+ if (stmt.type === 'VariableDeclaration') {
458
+ for (const decl of stmt.declarations ?? []) {
459
+ if (decl.id?.type === 'Identifier' && signalVars.has(decl.id.name)) {
460
+ // Only shadow if it's NOT a signal() call
461
+ if (!decl.init || !isSignalCall(decl.init)) {
462
+ shadows.push(decl.id.name)
463
+ }
464
+ }
465
+ }
466
+ }
467
+ }
468
+ return shadows
469
+ }
470
+
471
+ function readsFromProps(node: N): boolean {
472
+ if (node.type === 'MemberExpression' && node.object?.type === 'Identifier') {
473
+ if (propsNames.has(node.object.name)) return true
319
474
  }
320
- // Check children recursively — e.g. props.x ?? 'default'
321
475
  let found = false
322
- ts.forEachChild(node, (child) => {
476
+ forEachChildFast(node, (child) => {
323
477
  if (found) return
324
478
  if (readsFromProps(child)) found = true
325
479
  })
326
480
  return found
327
481
  }
328
482
 
329
- /** Pre-pass: scan a function body for prop-derived variable declarations.
330
- * callbackDepth tracks nesting inside callback arguments (map, filter, etc.)
331
- * to avoid tracking variables declared inside callbacks as prop-derived. */
332
- let _callbackDepth = 0
333
- function scanForPropDerivedVars(node: ts.Node): void {
334
- // Track callback nesting — don't track vars inside callbacks
335
- if ((ts.isArrowFunction(node) || ts.isFunctionExpression(node))) {
336
- const parent = node.parent
337
- if (parent && ts.isCallExpression(parent) && parent.arguments.includes(node as any)) {
338
- _callbackDepth++
339
- ts.forEachChild(node, scanForPropDerivedVars)
340
- _callbackDepth--
341
- return
342
- }
483
+ /** Check if an expression references any prop-derived variable. */
484
+ function referencesPropDerived(node: N): boolean {
485
+ if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
486
+ const p = findParent(node)
487
+ if (p && p.type === 'MemberExpression' && p.property === node && !p.computed) return false
488
+ return true
343
489
  }
344
- // Track the function's first parameter as a props name.
345
- // Only for COMPONENT functions — not callbacks like .map(item => <div>...)
346
- // Heuristic: component functions are named declarations, const assignments,
347
- // or export defaults — NOT inline arguments to calls like .map(), .filter().
348
- if ((ts.isFunctionDeclaration(node) || ts.isArrowFunction(node) || ts.isFunctionExpression(node))
349
- && node.parameters.length > 0) {
350
-
351
- // Skip functions that are arguments to a call (map/filter callbacks)
352
- const parent = node.parent
353
- if (parent && ts.isCallExpression(parent) && parent.arguments.includes(node as any)) {
354
- ts.forEachChild(node, scanForPropDerivedVars)
355
- return
356
- }
490
+ let found = false
491
+ forEachChildFast(node, (child) => {
492
+ if (found) return
493
+ if (referencesPropDerived(child)) found = true
494
+ })
495
+ return found
496
+ }
357
497
 
358
- const firstParam = node.parameters[0]!
359
- if (ts.isIdentifier(firstParam.name)) {
360
- // Check if this function returns JSX (is a component)
361
- let hasJSX = false
362
- ts.forEachChild(node, function checkJSX(n) {
363
- if (hasJSX) return
364
- if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) {
365
- hasJSX = true
366
- return
498
+ /** Collect prop-derived variable info from a VariableDeclaration node.
499
+ * Called inline during the single-pass walk when we encounter a declaration. */
500
+ function collectPropDerivedFromDecl(node: N, callbackDepth: number): void {
501
+ if (node.type !== 'VariableDeclaration') return
502
+ for (const decl of node.declarations ?? []) {
503
+ // splitProps: const [own, rest] = splitProps(props, [...])
504
+ if (decl.id?.type === 'ArrayPattern' && decl.init?.type === 'CallExpression') {
505
+ const callee = decl.init.callee
506
+ if (callee?.type === 'Identifier' && callee.name === 'splitProps') {
507
+ for (const el of decl.id.elements ?? []) {
508
+ if (el?.type === 'Identifier') propsNames.add(el.name)
367
509
  }
368
- ts.forEachChild(n, checkJSX)
369
- })
370
- if (hasJSX) propsNames.add(firstParam.name.text)
510
+ }
371
511
  }
372
- }
373
-
374
- // Track splitProps results: const [own, rest] = splitProps(props, [...])
375
- if (ts.isVariableStatement(node)) {
376
- for (const decl of node.declarationList.declarations) {
377
- if (ts.isArrayBindingPattern(decl.name) && decl.initializer
378
- && ts.isCallExpression(decl.initializer)) {
379
- const callee = decl.initializer.expression
380
- if (ts.isIdentifier(callee) && callee.text === 'splitProps') {
381
- for (const el of decl.name.elements) {
382
- if (ts.isBindingElement(el) && ts.isIdentifier(el.name)) {
383
- propsNames.add(el.name.text)
384
- }
385
- }
386
- }
512
+ if (node.kind !== 'const') continue
513
+ if (callbackDepth > 0) continue
514
+ if (decl.id?.type === 'Identifier' && decl.init) {
515
+ if (isStatefulCall(decl.init)) {
516
+ // Track signal() declarations for auto-call in JSX
517
+ if (isSignalCall(decl.init)) signalVars.add(decl.id.name)
518
+ continue
387
519
  }
388
-
389
- // Track: const x = props.y ?? z OR const x = own.y
390
- // Skip let/var mutable variables can be reassigned, unsafe to inline
391
- // Skip declarations inside callbacks (map, filter, etc.)
392
- // Skip stateful calls (signal, computed, effect) — inlining creates new instances
393
- if (!(node.declarationList.flags & ts.NodeFlags.Const)) continue
394
- if (_callbackDepth > 0) continue
395
- if (ts.isIdentifier(decl.name) && decl.initializer) {
396
- if (isStatefulCall(decl.initializer)) continue
397
- if (readsFromProps(decl.initializer)) {
398
- propDerivedVars.set(decl.name.text, decl.initializer)
399
- }
520
+ // Direct prop read OR transitive (references another prop-derived var)
521
+ if (readsFromProps(decl.init) || referencesPropDerived(decl.init)) {
522
+ propDerivedVars.set(decl.id.name, { start: decl.init.start as number, end: decl.init.end as number })
400
523
  }
401
524
  }
402
525
  }
403
-
404
- ts.forEachChild(node, scanForPropDerivedVars)
405
526
  }
406
527
 
407
- // Run pre-pass
408
- scanForPropDerivedVars(sf)
409
-
410
- // Transitive resolution: if const b = a + 1 where a is prop-derived,
411
- // then b is also prop-derived. Store its AST node.
412
- // Fixed-point iteration until no new variables are added.
413
- let changed = true
414
- while (changed) {
415
- changed = false
416
- sf.forEachChild(function scanTransitive(node) {
417
- if (!ts.isVariableStatement(node)) { ts.forEachChild(node, scanTransitive); return }
418
- for (const decl of node.declarationList.declarations) {
419
- if (!ts.isIdentifier(decl.name) || !decl.initializer) continue
420
- const varName = decl.name.text
421
- if (propDerivedVars.has(varName)) continue
422
- if (node.declarationList.flags & ts.NodeFlags.Let) continue
423
- let usesPropVar = false
424
- ts.forEachChild(decl.initializer, function check(n) {
425
- if (usesPropVar) return
426
- if (ts.isIdentifier(n) && propDerivedVars.has(n.text)) {
427
- const parent = n.parent
428
- if (parent && ts.isPropertyAccessExpression(parent) && parent.name === n) return
429
- usesPropVar = true
430
- }
431
- ts.forEachChild(n, check)
432
- })
433
- if (usesPropVar) {
434
- propDerivedVars.set(varName, decl.initializer)
435
- changed = true
528
+ /** Detect component functions and register their first param as a props name.
529
+ * Called inline during the walk when entering a function. */
530
+ function maybeRegisterComponentProps(node: N): void {
531
+ if (
532
+ (node.type === 'FunctionDeclaration' || node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') &&
533
+ (node.params?.length ?? 0) > 0
534
+ ) {
535
+ const parent = findParent(node)
536
+ // Skip callback functions (arguments to calls like .map, .filter)
537
+ if (parent && parent.type === 'CallExpression' && (parent.arguments ?? []).includes(node)) return
538
+ const firstParam = node.params[0]
539
+ if (firstParam?.type === 'Identifier') {
540
+ let hasJSX = false
541
+ function checkJSX(n: N): void {
542
+ if (hasJSX) return
543
+ if (n.type === 'JSXElement' || n.type === 'JSXFragment') { hasJSX = true; return }
544
+ forEachChildFast(n, checkJSX)
436
545
  }
546
+ forEachChildFast(node, checkJSX)
547
+ if (hasJSX) propsNames.add(firstParam.name)
437
548
  }
438
- })
549
+ }
439
550
  }
440
551
 
441
- // Resolve transitive AST: for each prop-derived var, recursively replace
442
- // references to other prop-derived vars in its AST with their resolved nodes.
443
- // Uses ts.visitNode for correct AST transformation — no string manipulation.
444
- //
445
- // The `visited` set prevents infinite recursion on circular references:
446
- // const a = b + props.x; const b = a + 1;
447
- // Without it, resolving `a` reaches `b`, which reaches `a` again, and
448
- // the compiler stack-overflows. The fix: when a variable is already in
449
- // the visited set, leave the identifier as-is (it falls back to the
450
- // captured const value, which is the correct runtime behavior for a
451
- // circular dependency — the variable reads its value at definition time).
452
- // Track which cycles have been warned about so we don't emit
453
- // duplicate warnings for the same cycle seen from different vars.
552
+ // ── String-based transitive resolution ─────────────────────────────────────
553
+ const resolvedCache = new Map<string, string>()
554
+ const resolving = new Set<string>()
454
555
  const warnedCycles = new Set<string>()
455
556
 
456
- function resolveExprTransitive(
457
- node: ts.Expression,
458
- visited: Set<string> = new Set(),
459
- /** The source node used for warning locations. */
460
- sourceNode?: ts.Node,
461
- ): ts.Expression {
462
- return ts.visitNode(node, function visit(n: ts.Node): ts.Node {
463
- if (ts.isIdentifier(n) && propDerivedVars.has(n.text)) {
464
- // FIRST: skip non-reference positions. An identifier is NOT a
465
- // variable read when it's a property name, binding name, or
466
- // shorthand property key. Those positions can happen to match
467
- // a tracked variable name (e.g. `own.beforeContentDirection`
468
- // when `beforeContentDirection` is also a tracked const) and
469
- // would otherwise trigger false-positive cycle warnings.
470
- //
471
- // This check MUST run before the cycle check — otherwise a
472
- // property access like `own.X` where X matches a tracked var
473
- // that's already in `visited` would falsely fire the warning.
474
- const parent = n.parent
475
- if (parent) {
476
- // Declaration positions — identifier defines a name, not reads one.
477
- // Also catches PropertyAccessExpression.name (property access),
478
- // VariableDeclaration.name (binding), PropertyAssignment.name
479
- // (object literal key), etc.
480
- if ('name' in parent && (parent as any).name === n) return n
481
- // Shorthand property: { x } — the identifier is both key and value
482
- if (ts.isShorthandPropertyAssignment(parent)) return n
483
- }
557
+ function resolveVarToString(varName: string, sourceNode?: N): string {
558
+ if (resolvedCache.has(varName)) return resolvedCache.get(varName)!
559
+ if (resolving.has(varName)) {
560
+ const cycleKey = [...resolving, varName].sort().join(',')
561
+ if (!warnedCycles.has(cycleKey)) {
562
+ warnedCycles.add(cycleKey)
563
+ const chain = [...resolving, varName].join(' ')
564
+ warn(
565
+ sourceNode ?? program,
566
+ `[Pyreon] Circular prop-derived const reference: ${chain}. ` +
567
+ `The cyclic identifier \`${varName}\` will use its captured value ` +
568
+ `instead of being reactively inlined. Break the cycle by reading ` +
569
+ `from \`props.*\` directly or restructuring the derivation chain.`,
570
+ 'circular-prop-derived',
571
+ )
572
+ }
573
+ return varName
574
+ }
575
+ resolving.add(varName)
576
+ const span = propDerivedVars.get(varName)!
577
+ const rawText = code.slice(span.start, span.end)
578
+ const resolved = resolveIdentifiersInText(rawText, span.start, sourceNode)
579
+ resolving.delete(varName)
580
+ resolvedCache.set(varName, resolved)
581
+ return resolved
582
+ }
484
583
 
485
- // Cycle detection: if this variable is already in the visited
486
- // set, we've found a circular reference. Leave the identifier
487
- // as-is (falls back to captured const value) and emit a
488
- // compiler warning so the developer knows reactivity is
489
- // incomplete on this chain.
490
- if (visited.has(n.text)) {
491
- const cycleKey = [...visited, n.text].sort().join(',')
492
- if (!warnedCycles.has(cycleKey)) {
493
- warnedCycles.add(cycleKey)
494
- const chain = [...visited, n.text].join(' ')
495
- warn(
496
- sourceNode ?? n,
497
- `[Pyreon] Circular prop-derived const reference: ${chain}. ` +
498
- `The cyclic identifier \`${n.text}\` will use its captured value ` +
499
- `instead of being reactively inlined. Break the cycle by reading ` +
500
- `from \`props.*\` directly or restructuring the derivation chain.`,
501
- 'circular-prop-derived',
502
- )
584
+ function resolveIdentifiersInText(text: string, baseOffset: number, sourceNode?: N): string {
585
+ const endOffset = baseOffset + text.length
586
+ const idents: { start: number; end: number; name: string }[] = []
587
+
588
+ // Walk the AST to find identifiers in the span, passing parent context
589
+ // to skip non-reference positions (property names, declarations, etc.)
590
+ function findIdents(node: N, parent: N | null): void {
591
+ const nodeStart = node.start as number
592
+ const nodeEnd = node.end as number
593
+ if (nodeStart >= endOffset || nodeEnd <= baseOffset) return
594
+ if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
595
+ if (parent) {
596
+ if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) { /* skip */ }
597
+ else if (parent.type === 'VariableDeclarator' && parent.id === node) { /* skip */ }
598
+ else if (parent.type === 'Property' && parent.key === node && !parent.computed) { /* skip */ }
599
+ else if (parent.type === 'Property' && parent.shorthand) { /* skip */ }
600
+ else if (nodeStart >= baseOffset && nodeEnd <= endOffset) {
601
+ idents.push({ start: nodeStart, end: nodeEnd, name: node.name })
503
602
  }
504
- return n
603
+ } else if (nodeStart >= baseOffset && nodeEnd <= endOffset) {
604
+ idents.push({ start: nodeStart, end: nodeEnd, name: node.name })
505
605
  }
506
-
507
- const resolved = propDerivedVars.get(n.text)!
508
- // Mark this variable as visited BEFORE recursing so cycles are
509
- // detected on the next encounter rather than re-entering.
510
- const nextVisited = new Set(visited)
511
- nextVisited.add(n.text)
512
- return ts.factory.createParenthesizedExpression(
513
- resolveExprTransitive(resolved, nextVisited, sourceNode),
514
- )
515
606
  }
516
- return ts.visitEachChild(n, visit, undefined as any)
517
- }) as ts.Expression
607
+ forEachChildFast(node, (child) => findIdents(child, node))
608
+ }
609
+ findIdents(program, null)
610
+
611
+ if (idents.length === 0) return text
612
+
613
+ idents.sort((a, b) => a.start - b.start)
614
+ const parts: string[] = []
615
+ let lastPos = baseOffset
616
+ for (const id of idents) {
617
+ parts.push(code.slice(lastPos, id.start))
618
+ parts.push(`(${resolveVarToString(id.name, sourceNode)})`)
619
+ lastPos = id.end
620
+ }
621
+ parts.push(code.slice(lastPos, endOffset))
622
+ return parts.join('')
518
623
  }
519
624
 
520
- /** Print an AST expression back to source text. */
521
- const printer = ts.createPrinter({ removeComments: false })
625
+ // ── Analysis helpers with memoization (Phase 3) ────────────────────────────
626
+ // Cache results keyed by node.start (unique per node in a file).
627
+ // Eliminates redundant subtree traversals for containsCall + accessesProps.
628
+ const _isDynamicCache = new Map<number, boolean>()
629
+
630
+ /** Fused isDynamic: checks both containsCall and accessesProps in one traversal. */
631
+ function isDynamic(node: N): boolean {
632
+ const key = node.start as number
633
+ const cached = _isDynamicCache.get(key)
634
+ if (cached !== undefined) return cached
635
+ const result = _isDynamicImpl(node)
636
+ _isDynamicCache.set(key, result)
637
+ return result
638
+ }
522
639
 
523
- /**
524
- * Enhanced dynamic check — combines containsCall with props awareness.
525
- * Returns true if an expression is reactive (contains signal calls,
526
- * accesses props members, or references prop-derived variables).
527
- */
528
- function isDynamic(node: ts.Node): boolean {
529
- if (containsCall(node)) return true
530
- return accessesProps(node)
640
+ function _isDynamicImpl(node: N): boolean {
641
+ // Call expression (non-pure)
642
+ if (node.type === 'CallExpression') {
643
+ if (!isPureStaticCall(node)) return true
644
+ }
645
+ if (node.type === 'TaggedTemplateExpression') return true
646
+ // Props access
647
+ if (node.type === 'MemberExpression' && !node.computed && node.object?.type === 'Identifier') {
648
+ if (propsNames.has(node.object.name)) return true
649
+ }
650
+ // Prop-derived variable reference
651
+ if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
652
+ const parent = findParent(node)
653
+ if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) {
654
+ // This is a property name position, not a reference — fall through
655
+ } else {
656
+ return true
657
+ }
658
+ }
659
+ // Signal variable reference — treated as dynamic (will be auto-called)
660
+ if (node.type === 'Identifier' && isActiveSignal(node.name)) {
661
+ const parent = findParent(node)
662
+ if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) {
663
+ // Property name position — not a reference
664
+ } else if (parent && parent.type === 'CallExpression' && parent.callee === node) {
665
+ // Already being called: signal() — don't double-flag
666
+ } else {
667
+ return true
668
+ }
669
+ }
670
+ // Don't recurse into nested functions
671
+ if (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') return false
672
+ // Recurse into children
673
+ let found = false
674
+ forEachChildFast(node, (child) => {
675
+ if (found) return
676
+ if (isDynamic(child)) found = true
677
+ })
678
+ return found
531
679
  }
532
680
 
533
- /** Check if an expression accesses a tracked props object or a prop-derived variable. */
534
- function accessesProps(node: ts.Node): boolean {
535
- if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
536
- if (propsNames.has(node.expression.text)) return true
681
+ /** accessesProps kept for sliceExpr's quick check (does this need resolution?) */
682
+ function accessesProps(node: N): boolean {
683
+ if (node.type === 'MemberExpression' && !node.computed && node.object?.type === 'Identifier') {
684
+ if (propsNames.has(node.object.name)) return true
537
685
  }
538
- if (ts.isIdentifier(node) && propDerivedVars.has(node.text)) {
539
- const parent = node.parent
540
- if (parent && ts.isPropertyAccessExpression(parent) && parent.name === node) return false
686
+ if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
687
+ const parent = findParent(node)
688
+ if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return false
541
689
  return true
542
690
  }
543
691
  let found = false
544
- ts.forEachChild(node, (child) => {
692
+ forEachChildFast(node, (child) => {
545
693
  if (found) return
546
- if (ts.isArrowFunction(child) || ts.isFunctionExpression(child)) return
694
+ if (child.type === 'ArrowFunctionExpression' || child.type === 'FunctionExpression') return
547
695
  if (accessesProps(child)) found = true
548
696
  })
549
697
  return found
550
698
  }
551
699
 
552
- function shouldWrap(node: ts.Expression): boolean {
553
- if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return false
700
+ function shouldWrap(node: N): boolean {
701
+ if (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') return false
554
702
  if (isStatic(node)) return false
555
- if (ts.isCallExpression(node) && isPureStaticCall(node)) return false
703
+ if (node.type === 'CallExpression' && isPureStaticCall(node)) return false
556
704
  return isDynamic(node)
557
705
  }
558
706
 
559
- function walk(node: ts.Node): void {
560
- if (ts.isJsxElement(node) && tryTemplateEmit(node)) return
561
- if (ts.isJsxSelfClosingElement(node) || ts.isJsxElement(node)) checkForWarnings(node)
562
- if (ts.isJsxAttribute(node)) {
563
- handleJsxAttribute(node)
707
+ // ── Single unified walk (Phase 2) ─────────────────────────────────────────
708
+ // Merges the old 3-pass architecture (scanForPropDerivedVars + transitive
709
+ // resolution + JSX walk) into one top-down traversal. Works because `const`
710
+ // declarations have a temporal dead zone — they're always before their use.
711
+ let _callbackDepth = 0
712
+
713
+ function walkNode(node: N): void {
714
+ // ── Component function detection (was pass 1) ──
715
+ const isFunction = node.type === 'FunctionDeclaration' || node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression'
716
+ let scopeShadows: string[] | null = null
717
+ if (isFunction) {
718
+ // Track callback nesting for prop-derived var exclusion
719
+ const parent = findParent(node)
720
+ const isCallbackArg = parent && parent.type === 'CallExpression' && (parent.arguments ?? []).includes(node)
721
+ if (isCallbackArg) _callbackDepth++
722
+ // Register component props (only for non-callback functions with JSX)
723
+ maybeRegisterComponentProps(node)
724
+ // Track signal name shadowing for scope awareness
725
+ if (signalVars.size > 0) {
726
+ scopeShadows = findShadowingNames(node)
727
+ for (const name of scopeShadows) shadowedSignals.add(name)
728
+ }
729
+ }
730
+
731
+ // ── Variable declaration collection (was pass 1 + 2) ──
732
+ if (node.type === 'VariableDeclaration') {
733
+ collectPropDerivedFromDecl(node, _callbackDepth)
734
+ }
735
+
736
+ // ── JSX processing (was pass 3) ──
737
+ if (node.type === 'JSXElement') {
738
+ if (!isSelfClosing(node) && tryTemplateEmit(node)) {
739
+ // Template emitted — don't recurse into this subtree (JSXElement is never a function)
740
+ return
741
+ }
742
+ checkForWarnings(node)
743
+ for (const attr of jsxAttrs(node)) {
744
+ if (attr.type === 'JSXAttribute') handleJsxAttribute(attr, node)
745
+ }
746
+ for (const child of jsxChildren(node)) {
747
+ if (child.type === 'JSXExpressionContainer') handleJsxExpression(child)
748
+ else walkNode(child)
749
+ }
750
+ // Note: JSXElement is never a function, so no callback depth or scope cleanup needed here
564
751
  return
565
752
  }
566
- if (ts.isJsxExpression(node)) {
753
+ if (node.type === 'JSXExpressionContainer') {
567
754
  handleJsxExpression(node)
755
+ // Note: JSXExpressionContainer is never a function, no scope cleanup needed
568
756
  return
569
757
  }
570
- ts.forEachChild(node, walk)
758
+
759
+ // Generic descent
760
+ forEachChildFast(node, walkNode)
761
+
762
+ // Restore callback depth after leaving function
763
+ if (isFunction) {
764
+ const parent = findParent(node)
765
+ if (parent && parent.type === 'CallExpression' && (parent.arguments ?? []).includes(node)) _callbackDepth--
766
+ }
767
+ // Restore signal shadowing
768
+ if (scopeShadows) for (const name of scopeShadows) shadowedSignals.delete(name)
571
769
  }
572
770
 
573
- walk(sf)
771
+ walkNode(program)
574
772
 
575
773
  if (replacements.length === 0 && hoists.length === 0) return { code, warnings }
576
774
 
577
- // Apply replacements left-to-right via string builder — O(n) single join
578
775
  replacements.sort((a, b) => a.start - b.start)
579
-
580
776
  const parts: string[] = []
581
777
  let lastPos = 0
582
778
  for (const r of replacements) {
@@ -587,13 +783,11 @@ export function transformJSX(
587
783
  parts.push(code.slice(lastPos))
588
784
  let result = parts.join('')
589
785
 
590
- // Prepend module-scope hoisted static VNode declarations
591
786
  if (hoists.length > 0) {
592
787
  const preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join('')
593
788
  result = preamble + result
594
789
  }
595
790
 
596
- // Prepend template imports if _tpl() was emitted
597
791
  if (needsTplImport) {
598
792
  const runtimeDomImports = ['_tpl']
599
793
  if (needsBindDirectImportGlobal) runtimeDomImports.push('_bindDirect')
@@ -608,65 +802,45 @@ export function transformJSX(
608
802
  result
609
803
  }
610
804
 
611
- // Prepend _rp import if reactive component props were emitted
612
805
  if (needsRpImport) {
613
806
  result = `import { _rp } from "@pyreon/core";\n` + result
614
807
  }
615
808
 
616
809
  return { code: result, usesTemplates: needsTplImport, warnings }
617
810
 
618
- // ── Template emission helpers (closures over sf, code) ──────────────────────
811
+ // ── Template emission helpers ─────────────────────────────────────────────
619
812
 
620
- /**
621
- * Check if attributes prevent template emission.
622
- * - `key` always bails (VNode reconciliation prop)
623
- * - Spread on inner elements bails (too complex to merge in _bind)
624
- * - Spread on root element is allowed — applied via applyProps in _bind
625
- */
626
- function hasBailAttr(node: ts.JsxElement | ts.JsxSelfClosingElement, isRoot = false): boolean {
813
+ function hasBailAttr(node: N, isRoot = false): boolean {
627
814
  for (const attr of jsxAttrs(node)) {
628
- if (ts.isJsxSpreadAttribute(attr)) {
629
- // Allow spread on root element — handled in buildTemplateCall
815
+ if (attr.type === 'JSXSpreadAttribute') {
630
816
  if (isRoot) continue
631
817
  return true
632
818
  }
633
- if (ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name) && attr.name.text === 'key')
819
+ if (attr.type === 'JSXAttribute' && attr.name?.type === 'JSXIdentifier' && attr.name.name === 'key')
634
820
  return true
635
821
  }
636
822
  return false
637
823
  }
638
824
 
639
- /**
640
- * Count template-eligible elements for a single JSX child.
641
- * Returns 0 for skippable children, -1 for bail, positive for element count.
642
- */
643
- function countChildForTemplate(child: ts.JsxChild): number {
644
- if (ts.isJsxText(child)) return 0
645
- if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child))
646
- return templateElementCount(child)
647
- if (ts.isJsxExpression(child)) {
648
- if (!child.expression) return 0
649
- return containsJSXInExpr(child.expression) ? -1 : 0
650
- }
651
- if (ts.isJsxFragment(child)) return templateFragmentCount(child)
825
+ function countChildForTemplate(child: N): number {
826
+ if (child.type === 'JSXText') return 0
827
+ if (child.type === 'JSXElement') return templateElementCount(child)
828
+ if (child.type === 'JSXExpressionContainer') {
829
+ const expr = child.expression
830
+ if (!expr || expr.type === 'JSXEmptyExpression') return 0
831
+ return containsJSXInExpr(expr) ? -1 : 0
832
+ }
833
+ if (child.type === 'JSXFragment') return templateFragmentCount(child)
652
834
  return -1
653
835
  }
654
836
 
655
- /**
656
- * Count DOM elements in a JSX subtree. Returns -1 if the tree is not
657
- * eligible for template emission.
658
- */
659
- function templateElementCount(
660
- node: ts.JsxElement | ts.JsxSelfClosingElement,
661
- isRoot = false,
662
- ): number {
837
+ function templateElementCount(node: N, isRoot = false): number {
663
838
  const tag = jsxTagName(node)
664
839
  if (!tag || !isLowerCase(tag)) return -1
665
840
  if (hasBailAttr(node, isRoot)) return -1
666
- if (!ts.isJsxElement(node)) return 1
667
-
841
+ if (isSelfClosing(node)) return 1
668
842
  let count = 1
669
- for (const child of node.children) {
843
+ for (const child of jsxChildren(node)) {
670
844
  const c = countChildForTemplate(child)
671
845
  if (c === -1) return -1
672
846
  count += c
@@ -674,10 +848,9 @@ export function transformJSX(
674
848
  return count
675
849
  }
676
850
 
677
- /** Count template-eligible elements inside a fragment. */
678
- function templateFragmentCount(frag: ts.JsxFragment): number {
851
+ function templateFragmentCount(frag: N): number {
679
852
  let count = 0
680
- for (const child of frag.children) {
853
+ for (const child of jsxChildren(frag)) {
681
854
  const c = countChildForTemplate(child)
682
855
  if (c === -1) return -1
683
856
  count += c
@@ -685,35 +858,25 @@ export function transformJSX(
685
858
  return count
686
859
  }
687
860
 
688
- /**
689
- * Build the complete `_tpl("html", (__root) => { ... })` call string
690
- * for a template-eligible JSX element tree. Returns null if codegen fails.
691
- */
692
- function buildTemplateCall(node: ts.JsxElement | ts.JsxSelfClosingElement): string | null {
861
+ function buildTemplateCall(node: N): string | null {
693
862
  const bindLines: string[] = []
694
863
  const disposerNames: string[] = []
695
864
  let varIdx = 0
696
865
  let dispIdx = 0
697
- // Reactive expressions that will be combined into a single _bind call
698
866
  const reactiveBindExprs: string[] = []
699
867
  let needsBindTextImport = false
700
868
  let needsBindDirectImport = false
701
869
  let needsApplyPropsImport = false
702
870
  let needsMountSlotImport = false
703
871
 
704
- function nextVar(): string {
705
- return `__e${varIdx++}`
706
- }
872
+ function nextVar(): string { return `__e${varIdx++}` }
707
873
  function nextDisp(): string {
708
874
  const name = `__d${dispIdx++}`
709
875
  disposerNames.push(name)
710
876
  return name
711
877
  }
712
- function nextTextVar(): string {
713
- return `__t${varIdx++}`
714
- }
878
+ function nextTextVar(): string { return `__t${varIdx++}` }
715
879
 
716
- /** Resolve the variable name for an element given its accessor path. */
717
880
  function resolveElementVar(accessor: string, hasDynamic: boolean): string {
718
881
  if (accessor === '__root') return '__root'
719
882
  if (hasDynamic) {
@@ -724,14 +887,11 @@ export function transformJSX(
724
887
  return accessor
725
888
  }
726
889
 
727
- /** Emit bind line for a ref attribute. */
728
- function emitRef(attr: ts.JsxAttribute, varName: string): void {
729
- if (!attr.initializer || !ts.isJsxExpression(attr.initializer)) return
730
- const expr = attr.initializer.expression
731
- if (!expr) return
732
- // Function ref: ref={(el) => { ... }} or ref={fn} → call with element
733
- // Object ref: ref={myRef} → assign element to .current
734
- if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) {
890
+ function emitRef(attr: N, varName: string): void {
891
+ if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return
892
+ const expr = attr.value.expression
893
+ if (!expr || expr.type === 'JSXEmptyExpression') return
894
+ if (expr.type === 'ArrowFunctionExpression' || expr.type === 'FunctionExpression') {
735
895
  bindLines.push(`(${sliceExpr(expr)})(${varName})`)
736
896
  } else {
737
897
  bindLines.push(
@@ -740,87 +900,67 @@ export function transformJSX(
740
900
  }
741
901
  }
742
902
 
743
- /** Emit event handler bind line — delegated (expando) or addEventListener. */
744
- function emitEventListener(attr: ts.JsxAttribute, attrName: string, varName: string): void {
903
+ function emitEventListener(attr: N, attrName: string, varName: string): void {
745
904
  const eventName = (attrName[2] ?? '').toLowerCase() + attrName.slice(3)
746
- if (!attr.initializer || !ts.isJsxExpression(attr.initializer)) return
747
- if (!attr.initializer.expression) return
748
- const handler = sliceExpr(attr.initializer.expression)
905
+ if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return
906
+ const expr = attr.value.expression
907
+ if (!expr || expr.type === 'JSXEmptyExpression') return
908
+ const handler = sliceExpr(expr)
749
909
  if (DELEGATED_EVENTS.has(eventName)) {
750
- // Delegated: store handler as expando property — container listener picks it up
751
910
  bindLines.push(`${varName}.__ev_${eventName} = ${handler}`)
752
911
  } else {
753
912
  bindLines.push(`${varName}.addEventListener("${eventName}", ${handler})`)
754
913
  }
755
914
  }
756
915
 
757
- /** Return HTML string for a static attribute expression, or null if not static. */
758
- function staticAttrToHtml(exprNode: ts.Expression, htmlAttrName: string): string | null {
916
+ function staticAttrToHtml(exprNode: N, htmlAttrName: string): string | null {
759
917
  if (!isStatic(exprNode)) return null
760
- if (ts.isStringLiteral(exprNode)) return ` ${htmlAttrName}="${escapeHtmlAttr(exprNode.text)}"`
761
- if (ts.isNumericLiteral(exprNode)) return ` ${htmlAttrName}="${exprNode.text}"`
762
- if (exprNode.kind === ts.SyntaxKind.TrueKeyword) return ` ${htmlAttrName}`
918
+ // String literal
919
+ if ((exprNode.type === 'Literal' || exprNode.type === 'StringLiteral') && typeof exprNode.value === 'string')
920
+ return ` ${htmlAttrName}="${escapeHtmlAttr(exprNode.value)}"`
921
+ // Numeric literal
922
+ if ((exprNode.type === 'Literal' || exprNode.type === 'NumericLiteral') && typeof exprNode.value === 'number')
923
+ return ` ${htmlAttrName}="${exprNode.value}"`
924
+ // Boolean true
925
+ if ((exprNode.type === 'Literal' || exprNode.type === 'BooleanLiteral') && exprNode.value === true)
926
+ return ` ${htmlAttrName}`
763
927
  return '' // false/null/undefined → omit
764
928
  }
765
929
 
766
- /**
767
- * Try to extract a direct signal reference from an expression.
768
- * Returns the callee text (e.g. "count" or "row.label") if the expression
769
- * is a single call with no arguments, otherwise null.
770
- */
771
- function tryDirectSignalRef(exprNode: ts.Expression): string | null {
930
+ function tryDirectSignalRef(exprNode: N): string | null {
772
931
  let inner = exprNode
773
- // Unwrap concise arrow: () => expr
774
- if (ts.isArrowFunction(inner) && !ts.isBlock(inner.body)) {
775
- inner = inner.body as ts.Expression
776
- }
777
- if (!ts.isCallExpression(inner)) return null
778
- if (inner.arguments.length > 0) return null
779
- const callee = inner.expression
780
- // Only match simple identifiers: count() → _bindText(count, node)
781
- // Property access like obj.method() is NOT safe — detaching the method
782
- // loses `this` context (e.g. value.toLocaleString becomes unbound).
783
- if (ts.isIdentifier(callee)) {
784
- return sliceExpr(callee)
932
+ if (inner.type === 'ArrowFunctionExpression' && inner.body?.type !== 'BlockStatement') {
933
+ inner = inner.body
785
934
  }
935
+ if (inner.type !== 'CallExpression') return null
936
+ if ((inner.arguments?.length ?? 0) > 0) return null
937
+ const callee = inner.callee
938
+ if (callee?.type === 'Identifier') return sliceExpr(callee)
786
939
  return null
787
940
  }
788
941
 
789
- /** Unwrap a reactive accessor expression for use inside _bind(). */
790
- function unwrapAccessor(exprNode: ts.Expression): { expr: string; isReactive: boolean } {
791
- // Concise arrow: () => value() → unwrap to "value()"
792
- if (ts.isArrowFunction(exprNode) && !ts.isBlock(exprNode.body)) {
793
- return { expr: sliceExpr(exprNode.body as ts.Expression), isReactive: true }
942
+ function unwrapAccessor(exprNode: N): { expr: string; isReactive: boolean } {
943
+ if (exprNode.type === 'ArrowFunctionExpression' && exprNode.body?.type !== 'BlockStatement') {
944
+ return { expr: sliceExpr(exprNode.body), isReactive: true }
794
945
  }
795
- // Block-body arrow/function: invoke it
796
- if (ts.isArrowFunction(exprNode) || ts.isFunctionExpression(exprNode)) {
946
+ if (exprNode.type === 'ArrowFunctionExpression' || exprNode.type === 'FunctionExpression') {
797
947
  return { expr: `(${sliceExpr(exprNode)})()`, isReactive: true }
798
948
  }
799
949
  return { expr: sliceExpr(exprNode), isReactive: isDynamic(exprNode) }
800
950
  }
801
951
 
802
- /** Build a setter expression for an attribute. */
803
952
  function attrSetter(htmlAttrName: string, varName: string, expr: string): string {
804
953
  if (htmlAttrName === 'class') return `${varName}.className = ${expr}`
805
954
  if (htmlAttrName === 'style') return `${varName}.style.cssText = ${expr}`
806
955
  return `${varName}.setAttribute("${htmlAttrName}", ${expr})`
807
956
  }
808
957
 
809
- /** Emit bind line for a dynamic (non-static) attribute. */
810
- function emitDynamicAttr(
811
- _expr: string,
812
- exprNode: ts.Expression,
813
- htmlAttrName: string,
814
- varName: string,
815
- ): void {
958
+ function emitDynamicAttr(_expr: string, exprNode: N, htmlAttrName: string, varName: string): void {
816
959
  const { expr, isReactive } = unwrapAccessor(exprNode)
817
-
818
960
  if (!isReactive) {
819
961
  bindLines.push(attrSetter(htmlAttrName, varName, expr))
820
962
  return
821
963
  }
822
-
823
- // Direct signal binding for bare signal calls (e.g. class={() => active()})
824
964
  const directRef = tryDirectSignalRef(exprNode)
825
965
  if (directRef) {
826
966
  needsBindDirectImport = true
@@ -834,111 +974,79 @@ export function transformJSX(
834
974
  bindLines.push(`const ${d} = _bindDirect(${directRef}, ${updater})`)
835
975
  return
836
976
  }
837
-
838
977
  reactiveBindExprs.push(attrSetter(htmlAttrName, varName, expr))
839
978
  }
840
979
 
841
- /** Emit bind line or HTML for an expression attribute value. */
842
- function emitAttrExpression(
843
- exprNode: ts.Expression,
844
- htmlAttrName: string,
845
- varName: string,
846
- ): string {
980
+ function emitAttrExpression(exprNode: N, htmlAttrName: string, varName: string): string {
847
981
  const staticHtml = staticAttrToHtml(exprNode, htmlAttrName)
848
982
  if (staticHtml !== null) return staticHtml
849
-
850
- // style={{...}} → Object.assign(el.style, {...}) for object expressions
851
- if (htmlAttrName === 'style' && ts.isObjectLiteralExpression(exprNode)) {
983
+ if (htmlAttrName === 'style' && exprNode.type === 'ObjectExpression') {
852
984
  bindLines.push(`Object.assign(${varName}.style, ${sliceExpr(exprNode)})`)
853
985
  return ''
854
986
  }
855
-
856
987
  emitDynamicAttr(sliceExpr(exprNode), exprNode, htmlAttrName, varName)
857
988
  return ''
858
989
  }
859
990
 
860
- /** Emit side-effects for special attrs (ref, event). Returns true if handled. */
861
- function tryEmitSpecialAttr(attr: ts.JsxAttribute, attrName: string, varName: string): boolean {
862
- if (attrName === 'ref') {
863
- emitRef(attr, varName)
864
- return true
865
- }
866
- if (EVENT_RE.test(attrName)) {
867
- emitEventListener(attr, attrName, varName)
868
- return true
869
- }
991
+ function tryEmitSpecialAttr(attr: N, attrName: string, varName: string): boolean {
992
+ if (attrName === 'ref') { emitRef(attr, varName); return true }
993
+ if (EVENT_RE.test(attrName)) { emitEventListener(attr, attrName, varName); return true }
870
994
  return false
871
995
  }
872
996
 
873
- /** Convert an attribute initializer to HTML. Returns empty string for side-effect-only attrs. */
874
- function attrInitializerToHtml(
875
- attr: ts.JsxAttribute,
876
- htmlAttrName: string,
877
- varName: string,
878
- ): string {
879
- if (!attr.initializer) return ` ${htmlAttrName}`
880
- if (ts.isStringLiteral(attr.initializer))
881
- return ` ${htmlAttrName}="${escapeHtmlAttr(attr.initializer.text)}"`
882
- if (ts.isJsxExpression(attr.initializer) && attr.initializer.expression)
883
- return emitAttrExpression(attr.initializer.expression, htmlAttrName, varName)
997
+ function attrInitializerToHtml(attr: N, htmlAttrName: string, varName: string): string {
998
+ if (!attr.value) return ` ${htmlAttrName}`
999
+ // JSX string attribute: class="foo"
1000
+ if (attr.value.type === 'StringLiteral' || (attr.value.type === 'Literal' && typeof attr.value.value === 'string'))
1001
+ return ` ${htmlAttrName}="${escapeHtmlAttr(attr.value.value)}"`
1002
+ if (attr.value.type === 'JSXExpressionContainer') {
1003
+ const expr = attr.value.expression
1004
+ if (expr && expr.type !== 'JSXEmptyExpression') return emitAttrExpression(expr, htmlAttrName, varName)
1005
+ }
884
1006
  return ''
885
1007
  }
886
1008
 
887
- /** Process a single attribute, returning HTML to append. */
888
- function processOneAttr(attr: ts.JsxAttributeLike, varName: string): string {
889
- // Spread attribute: apply all props at runtime
890
- if (ts.isJsxSpreadAttribute(attr)) {
891
- const expr = sliceExpr(attr.expression)
892
- // Use runtime-dom's applyProps which handles class, style, events, etc.
1009
+ function processOneAttr(attr: N, varName: string): string {
1010
+ if (attr.type === 'JSXSpreadAttribute') {
1011
+ const expr = sliceExpr(attr.argument)
893
1012
  needsApplyPropsImport = true
894
- if (isDynamic(attr.expression)) {
1013
+ if (isDynamic(attr.argument)) {
895
1014
  reactiveBindExprs.push(`_applyProps(${varName}, ${expr})`)
896
1015
  } else {
897
1016
  bindLines.push(`_applyProps(${varName}, ${expr})`)
898
1017
  }
899
1018
  return ''
900
1019
  }
901
- if (!ts.isJsxAttribute(attr)) return ''
902
- const attrName = ts.isIdentifier(attr.name) ? attr.name.text : ''
1020
+ if (attr.type !== 'JSXAttribute') return ''
1021
+ const attrName = attr.name?.type === 'JSXIdentifier' ? attr.name.name : ''
903
1022
  if (attrName === 'key') return ''
904
1023
  if (tryEmitSpecialAttr(attr, attrName, varName)) return ''
905
1024
  return attrInitializerToHtml(attr, JSX_TO_HTML_ATTR[attrName] ?? attrName, varName)
906
1025
  }
907
1026
 
908
- /** Process all attributes on an element, returning the HTML attribute string. */
909
- function processAttrs(el: ts.JsxElement | ts.JsxSelfClosingElement, varName: string): string {
1027
+ function processAttrs(el: N, varName: string): string {
910
1028
  let htmlAttrs = ''
911
1029
  for (const attr of jsxAttrs(el)) htmlAttrs += processOneAttr(attr, varName)
912
1030
  return htmlAttrs
913
1031
  }
914
1032
 
915
- /** Emit bind lines for a reactive text expression child. */
916
1033
  function emitReactiveTextChild(
917
- expr: string,
918
- exprNode: ts.Expression,
919
- varName: string,
920
- parentRef: string,
921
- childNodeIdx: number,
922
- needsPlaceholder: boolean,
1034
+ expr: string, exprNode: N, varName: string,
1035
+ parentRef: string, childNodeIdx: number, needsPlaceholder: boolean,
923
1036
  ): string {
924
1037
  const tVar = nextTextVar()
925
1038
  bindLines.push(`const ${tVar} = document.createTextNode("")`)
926
1039
  if (needsPlaceholder) {
927
- bindLines.push(
928
- `${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`,
929
- )
1040
+ bindLines.push(`${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`)
930
1041
  } else {
931
1042
  bindLines.push(`${varName}.appendChild(${tVar})`)
932
1043
  }
933
- // Direct signal binding: bypass effect system entirely
934
1044
  const directRef = tryDirectSignalRef(exprNode)
935
1045
  if (directRef) {
936
1046
  needsBindTextImport = true
937
1047
  const d = nextDisp()
938
1048
  bindLines.push(`const ${d} = _bindText(${directRef}, ${tVar})`)
939
1049
  } else {
940
- // Each reactive text child gets its own _bind — independent tracking.
941
- // When r.name() changes, r.email()'s _bind doesn't re-run.
942
1050
  needsBindImportGlobal = true
943
1051
  const d = nextDisp()
944
1052
  bindLines.push(`const ${d} = _bind(() => { ${tVar}.data = ${expr} })`)
@@ -946,34 +1054,88 @@ export function transformJSX(
946
1054
  return needsPlaceholder ? '<!>' : ''
947
1055
  }
948
1056
 
949
- /** Emit bind lines for a static text expression child. */
950
1057
  function emitStaticTextChild(
951
- expr: string,
952
- varName: string,
953
- parentRef: string,
954
- childNodeIdx: number,
955
- needsPlaceholder: boolean,
1058
+ expr: string, varName: string,
1059
+ parentRef: string, childNodeIdx: number, needsPlaceholder: boolean,
956
1060
  ): string {
957
1061
  if (needsPlaceholder) {
958
1062
  const tVar = nextTextVar()
959
1063
  bindLines.push(`const ${tVar} = document.createTextNode(${expr})`)
960
- bindLines.push(
961
- `${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`,
962
- )
1064
+ bindLines.push(`${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`)
963
1065
  return '<!>'
964
1066
  }
965
1067
  bindLines.push(`${varName}.textContent = ${expr}`)
966
1068
  return ''
967
1069
  }
968
1070
 
969
- /** Process a single flat child, returning the HTML contribution or null on failure. */
1071
+ type FlatChild =
1072
+ | { kind: 'text'; text: string }
1073
+ | { kind: 'element'; node: N; elemIdx: number }
1074
+ | { kind: 'expression'; expression: N }
1075
+
1076
+ function classifyJsxChild(
1077
+ child: N, out: FlatChild[],
1078
+ elemIdxRef: { value: number },
1079
+ recurse: (kids: N[]) => void,
1080
+ ): void {
1081
+ if (child.type === 'JSXText') {
1082
+ const raw = child.value ?? child.raw ?? ''
1083
+ const trimmed = raw.replace(/\n\s*/g, '').trim()
1084
+ if (trimmed) out.push({ kind: 'text', text: trimmed })
1085
+ return
1086
+ }
1087
+ if (child.type === 'JSXElement') {
1088
+ out.push({ kind: 'element', node: child, elemIdx: elemIdxRef.value++ })
1089
+ return
1090
+ }
1091
+ if (child.type === 'JSXExpressionContainer') {
1092
+ const expr = child.expression
1093
+ if (expr && expr.type !== 'JSXEmptyExpression') out.push({ kind: 'expression', expression: expr })
1094
+ return
1095
+ }
1096
+ if (child.type === 'JSXFragment') recurse(jsxChildren(child))
1097
+ }
1098
+
1099
+ function flattenChildren(children: N[]): FlatChild[] {
1100
+ const flatList: FlatChild[] = []
1101
+ const elemIdxRef = { value: 0 }
1102
+ function addChildren(kids: N[]): void {
1103
+ for (const child of kids) classifyJsxChild(child, flatList, elemIdxRef, addChildren)
1104
+ }
1105
+ addChildren(children)
1106
+ return flatList
1107
+ }
1108
+
1109
+ function analyzeChildren(flatChildren: FlatChild[]): { useMixed: boolean; useMultiExpr: boolean } {
1110
+ const hasElem = flatChildren.some((c) => c.kind === 'element')
1111
+ const hasNonElem = flatChildren.some((c) => c.kind !== 'element')
1112
+ const exprCount = flatChildren.filter((c) => c.kind === 'expression').length
1113
+ return { useMixed: hasElem && hasNonElem, useMultiExpr: exprCount > 1 }
1114
+ }
1115
+
1116
+ function attrIsDynamic(attr: N): boolean {
1117
+ if (attr.type !== 'JSXAttribute') return false
1118
+ const name = attr.name?.type === 'JSXIdentifier' ? attr.name.name : ''
1119
+ if (name === 'ref') return true
1120
+ if (EVENT_RE.test(name)) return true
1121
+ if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return false
1122
+ const expr = attr.value.expression
1123
+ return expr && expr.type !== 'JSXEmptyExpression' ? !isStatic(expr) : false
1124
+ }
1125
+
1126
+ function elementHasDynamic(node: N): boolean {
1127
+ if (jsxAttrs(node).some(attrIsDynamic)) return true
1128
+ if (!isSelfClosing(node)) {
1129
+ return jsxChildren(node).some((c: N) =>
1130
+ c.type === 'JSXExpressionContainer' && c.expression && c.expression.type !== 'JSXEmptyExpression',
1131
+ )
1132
+ }
1133
+ return false
1134
+ }
1135
+
970
1136
  function processOneChild(
971
- child: FlatChild,
972
- varName: string,
973
- parentRef: string,
974
- useMixed: boolean,
975
- useMultiExpr: boolean,
976
- childNodeIdx: number,
1137
+ child: FlatChild, varName: string, parentRef: string,
1138
+ useMixed: boolean, useMultiExpr: boolean, childNodeIdx: number,
977
1139
  ): string | null {
978
1140
  if (child.kind === 'text') return escapeHtmlText(child.text)
979
1141
  if (child.kind === 'element') {
@@ -982,12 +1144,8 @@ export function transformJSX(
982
1144
  : `${parentRef}.children[${child.elemIdx}]`
983
1145
  return processElement(child.node, childAccessor)
984
1146
  }
985
- // expression
986
1147
  const needsPlaceholder = useMixed || useMultiExpr
987
1148
  const { expr, isReactive } = unwrapAccessor(child.expression)
988
-
989
- // Children slot: expression accesses .children (e.g. props.children, own.children)
990
- // These can contain VNodes — use _mountSlot instead of text node binding.
991
1149
  if (isChildrenExpression(child.expression, expr)) {
992
1150
  needsMountSlotImport = true
993
1151
  const placeholder = `${parentRef}.childNodes[${childNodeIdx}]`
@@ -995,64 +1153,38 @@ export function transformJSX(
995
1153
  bindLines.push(`const ${d} = _mountSlot(${expr}, ${parentRef}, ${placeholder})`)
996
1154
  return '<!>'
997
1155
  }
998
-
999
1156
  if (isReactive) {
1000
- return emitReactiveTextChild(
1001
- expr,
1002
- child.expression,
1003
- varName,
1004
- parentRef,
1005
- childNodeIdx,
1006
- needsPlaceholder,
1007
- )
1157
+ return emitReactiveTextChild(expr, child.expression, varName, parentRef, childNodeIdx, needsPlaceholder)
1008
1158
  }
1009
1159
  return emitStaticTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder)
1010
1160
  }
1011
1161
 
1012
- /** Process children of a JsxElement, returning the children HTML. */
1013
- function processChildren(el: ts.JsxElement, varName: string, accessor: string): string | null {
1014
- const flatChildren = flattenChildren(el.children)
1162
+ function processChildren(el: N, varName: string, accessor: string): string | null {
1163
+ const flatChildren = flattenChildren(jsxChildren(el))
1015
1164
  const { useMixed, useMultiExpr } = analyzeChildren(flatChildren)
1016
1165
  const parentRef = accessor === '__root' ? '__root' : varName
1017
-
1018
1166
  let html = ''
1019
1167
  let childNodeIdx = 0
1020
-
1021
1168
  for (const child of flatChildren) {
1022
- const childHtml = processOneChild(
1023
- child,
1024
- varName,
1025
- parentRef,
1026
- useMixed,
1027
- useMultiExpr,
1028
- childNodeIdx,
1029
- )
1169
+ const childHtml = processOneChild(child, varName, parentRef, useMixed, useMultiExpr, childNodeIdx)
1030
1170
  if (childHtml === null) return null
1031
1171
  html += childHtml
1032
1172
  childNodeIdx++
1033
1173
  }
1034
-
1035
1174
  return html
1036
1175
  }
1037
1176
 
1038
- /** Process a single DOM element for template emission. Returns the HTML string or null. */
1039
- function processElement(
1040
- el: ts.JsxElement | ts.JsxSelfClosingElement,
1041
- accessor: string,
1042
- ): string | null {
1177
+ function processElement(el: N, accessor: string): string | null {
1043
1178
  const tag = jsxTagName(el)
1044
1179
  if (!tag) return null
1045
-
1046
1180
  const varName = resolveElementVar(accessor, elementHasDynamic(el))
1047
1181
  const htmlAttrs = processAttrs(el, varName)
1048
1182
  let html = `<${tag}${htmlAttrs}>`
1049
-
1050
- if (ts.isJsxElement(el)) {
1183
+ if (!isSelfClosing(el)) {
1051
1184
  const childHtml = processChildren(el, varName, accessor)
1052
1185
  if (childHtml === null) return null
1053
1186
  html += childHtml
1054
1187
  }
1055
-
1056
1188
  if (!VOID_ELEMENTS.has(tag)) html += `</${tag}>`
1057
1189
  return html
1058
1190
  }
@@ -1065,15 +1197,8 @@ export function transformJSX(
1065
1197
  if (needsApplyPropsImport) needsApplyPropsImportGlobal = true
1066
1198
  if (needsMountSlotImport) needsMountSlotImportGlobal = true
1067
1199
 
1068
- // Build bind function body
1069
1200
  const escaped = html.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
1070
1201
 
1071
- // Emit combined _bind for reactive attribute/text expressions that
1072
- // weren't handled by _bindText. This merges N separate _bind calls into
1073
- // one — saving N-1 closures + deps arrays per template instance.
1074
- // Emit a single combined _bind for all reactive attribute/text expressions
1075
- // that weren't handled by _bindText. Merges N separate _bind calls into one —
1076
- // saving N-1 closures + deps arrays per template instance.
1077
1202
  if (reactiveBindExprs.length > 0) {
1078
1203
  needsBindImportGlobal = true
1079
1204
  const combinedName = nextDisp()
@@ -1095,127 +1220,92 @@ export function transformJSX(
1095
1220
  return `_tpl("${escaped}", (__root) => {\n${body}\n})`
1096
1221
  }
1097
1222
 
1098
- /** Flat child descriptor for template children processing */
1099
- type FlatChild =
1100
- | { kind: 'text'; text: string }
1101
- | { kind: 'element'; node: ts.JsxElement | ts.JsxSelfClosingElement; elemIdx: number }
1102
- | { kind: 'expression'; expression: ts.Expression }
1103
-
1104
- /** Classify a single JSX child into a FlatChild descriptor. */
1105
- function classifyJsxChild(
1106
- child: ts.JsxChild,
1107
- out: FlatChild[],
1108
- elemIdxRef: { value: number },
1109
- recurse: (kids: ts.NodeArray<ts.JsxChild>) => void,
1110
- ): void {
1111
- if (ts.isJsxText(child)) {
1112
- const trimmed = child.text.replace(/\n\s*/g, '').trim()
1113
- if (trimmed) out.push({ kind: 'text', text: trimmed })
1114
- return
1115
- }
1116
- if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
1117
- out.push({ kind: 'element', node: child, elemIdx: elemIdxRef.value++ })
1118
- return
1119
- }
1120
- if (ts.isJsxExpression(child)) {
1121
- if (child.expression) out.push({ kind: 'expression', expression: child.expression })
1122
- return
1223
+ function sliceExpr(expr: N): string {
1224
+ let result: string
1225
+ if (propDerivedVars.size > 0 && accessesProps(expr)) {
1226
+ const start = expr.start as number
1227
+ const end = expr.end as number
1228
+ result = resolveIdentifiersInText(code.slice(start, end), start, expr)
1229
+ } else {
1230
+ result = code.slice(expr.start as number, expr.end as number)
1123
1231
  }
1124
- if (ts.isJsxFragment(child)) recurse(child.children)
1125
- }
1126
-
1127
- /**
1128
- * Flatten JSX children, inlining fragment children and stripping whitespace-only text.
1129
- * Returns a flat array of child descriptors with element indices pre-computed.
1130
- */
1131
- function flattenChildren(children: ts.NodeArray<ts.JsxChild>): FlatChild[] {
1132
- const flatList: FlatChild[] = []
1133
- const elemIdxRef = { value: 0 }
1134
1232
 
1135
- function addChildren(kids: ts.NodeArray<ts.JsxChild>): void {
1136
- for (const child of kids) classifyJsxChild(child, flatList, elemIdxRef, addChildren)
1233
+ // Auto-call signal variables: replace bare `x` with `x()` in the expression.
1234
+ // Only applies to identifiers that are NOT already being called (not `x()`).
1235
+ if (signalVars.size > 0 && signalVars.size > shadowedSignals.size && referencesSignalVar(expr)) {
1236
+ result = autoCallSignals(result, expr)
1137
1237
  }
1138
1238
 
1139
- addChildren(children)
1140
- return flatList
1141
- }
1142
-
1143
- /** Analyze flat children to determine indexing strategy. */
1144
- function analyzeChildren(flatChildren: FlatChild[]): {
1145
- useMixed: boolean
1146
- useMultiExpr: boolean
1147
- } {
1148
- const hasElem = flatChildren.some((c) => c.kind === 'element')
1149
- const hasNonElem = flatChildren.some((c) => c.kind !== 'element')
1150
- const exprCount = flatChildren.filter((c) => c.kind === 'expression').length
1151
- return { useMixed: hasElem && hasNonElem, useMultiExpr: exprCount > 1 }
1239
+ return result
1152
1240
  }
1153
1241
 
1154
- /** Check if a single attribute is dynamic (has ref, event, or non-static expression). */
1155
- function attrIsDynamic(attr: ts.JsxAttributeLike): boolean {
1156
- if (!ts.isJsxAttribute(attr)) return false
1157
- const name = ts.isIdentifier(attr.name) ? attr.name.text : ''
1158
- if (name === 'ref') return true
1159
- if (EVENT_RE.test(name)) return true
1160
- if (!attr.initializer || !ts.isJsxExpression(attr.initializer)) return false
1161
- const expr = attr.initializer.expression
1162
- return expr ? !isStatic(expr) : false
1163
- }
1164
-
1165
- /** Check if an element has any dynamic attributes, events, ref, or expression children */
1166
- function elementHasDynamic(node: ts.JsxElement | ts.JsxSelfClosingElement): boolean {
1167
- if (jsxAttrs(node).some(attrIsDynamic)) return true
1168
- if (ts.isJsxElement(node)) {
1169
- return node.children.some((c) => ts.isJsxExpression(c) && c.expression !== undefined)
1242
+ /** Check if an expression references any tracked signal variable. */
1243
+ function referencesSignalVar(node: N): boolean {
1244
+ if (node.type === 'Identifier' && isActiveSignal(node.name)) {
1245
+ const parent = findParent(node)
1246
+ if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return false
1247
+ if (parent && parent.type === 'CallExpression' && parent.callee === node) return false // already called
1248
+ return true
1170
1249
  }
1171
- return false
1250
+ let found = false
1251
+ forEachChildFast(node, (child) => {
1252
+ if (found) return
1253
+ if (child.type === 'ArrowFunctionExpression' || child.type === 'FunctionExpression') return
1254
+ if (referencesSignalVar(child)) found = true
1255
+ })
1256
+ return found
1172
1257
  }
1173
1258
 
1174
- /** Slice expression source from the original code.
1175
- * Resolves any prop-derived identifiers found anywhere in the expression
1176
- * via AST transformation handles template literals, ternaries, etc. */
1177
- function sliceExpr(expr: ts.Expression): string {
1178
- // Quick check: does this expression contain any prop-derived references?
1179
- if (propDerivedVars.size > 0 && accessesProps(expr)) {
1180
- const resolved = resolveExprTransitive(expr, new Set(), expr)
1181
- return printer.printNode(ts.EmitHint.Expression, resolved, sf)
1259
+ /** Auto-insert () after signal variable references in the expression source.
1260
+ * Uses the AST to find exact Identifier positions — never scans raw text. */
1261
+ function autoCallSignals(text: string, expr: N): string {
1262
+ const start = expr.start as number
1263
+ // Collect signal identifier positions that need auto-calling
1264
+ const idents: { start: number; end: number }[] = []
1265
+
1266
+ function findSignalIdents(node: N): void {
1267
+ if ((node.start as number) >= start + text.length || (node.end as number) <= start) return
1268
+ if (node.type === 'Identifier' && isActiveSignal(node.name)) {
1269
+ const parent = findParent(node)
1270
+ // Skip property name positions (obj.name)
1271
+ if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return
1272
+ // Skip if already being called: signal()
1273
+ if (parent && parent.type === 'CallExpression' && parent.callee === node) return
1274
+ // Skip declaration positions
1275
+ if (parent && parent.type === 'VariableDeclarator' && parent.id === node) return
1276
+ // Skip object property keys and shorthand properties ({ name } or { name: val })
1277
+ // Inserting () after a shorthand key produces name() which is a method shorthand — invalid
1278
+ if (parent && (parent.type === 'Property' || parent.type === 'ObjectProperty')) {
1279
+ if (parent.shorthand) return // { name } — can't auto-call without breaking syntax
1280
+ if (parent.key === node && !parent.computed) return // { name: val } — key position
1281
+ }
1282
+ idents.push({ start: node.start as number, end: node.end as number })
1283
+ }
1284
+ forEachChildFast(node, findSignalIdents)
1182
1285
  }
1183
- return code.slice(expr.getStart(sf), expr.getEnd())
1184
- }
1185
-
1186
- /** Get tag name string */
1187
- function jsxTagName(node: ts.JsxElement | ts.JsxSelfClosingElement): string {
1188
- const tag = ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName
1189
- return ts.isIdentifier(tag) ? tag.text : ''
1190
- }
1191
-
1192
- /** Get attribute list */
1193
- function jsxAttrs(
1194
- node: ts.JsxElement | ts.JsxSelfClosingElement,
1195
- ): ts.NodeArray<ts.JsxAttributeLike> {
1196
- return ts.isJsxElement(node)
1197
- ? node.openingElement.attributes.properties
1198
- : node.attributes.properties
1286
+ findSignalIdents(expr)
1287
+
1288
+ if (idents.length === 0) return text
1289
+
1290
+ // Sort by position and insert () after each identifier
1291
+ idents.sort((a, b) => a.start - b.start)
1292
+ const parts: string[] = []
1293
+ let lastPos = start
1294
+ for (const id of idents) {
1295
+ parts.push(code.slice(lastPos, id.end))
1296
+ parts.push('()') // auto-call
1297
+ lastPos = id.end
1298
+ }
1299
+ parts.push(code.slice(lastPos, start + text.length))
1300
+ return parts.join('')
1199
1301
  }
1200
1302
  }
1201
1303
 
1202
- // ─── Template constants ──────────────────────────────────────────────────────
1304
+ // ─── Module-scope constants and helpers ─────────────────────────────────────
1203
1305
 
1204
1306
  const VOID_ELEMENTS = new Set([
1205
- 'area',
1206
- 'base',
1207
- 'br',
1208
- 'col',
1209
- 'embed',
1210
- 'hr',
1211
- 'img',
1212
- 'input',
1213
- 'link',
1214
- 'meta',
1215
- 'param',
1216
- 'source',
1217
- 'track',
1218
- 'wbr',
1307
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
1308
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
1219
1309
  ])
1220
1310
 
1221
1311
  const JSX_TO_HTML_ATTR: Record<string, string> = {
@@ -1223,11 +1313,6 @@ const JSX_TO_HTML_ATTR: Record<string, string> = {
1223
1313
  htmlFor: 'for',
1224
1314
  }
1225
1315
 
1226
- /**
1227
- * Detect if an expression is a stateful call that must NOT be inlined.
1228
- * signal(), computed(), effect() etc. create state — inlining them would
1229
- * create new instances at each use site instead of referencing the original.
1230
- */
1231
1316
  const STATEFUL_CALLS = new Set([
1232
1317
  'signal', 'computed', 'effect', 'batch',
1233
1318
  'createContext', 'createReactiveContext',
@@ -1236,24 +1321,23 @@ const STATEFUL_CALLS = new Set([
1236
1321
  'defineStore', 'useStore',
1237
1322
  ])
1238
1323
 
1239
- function isStatefulCall(node: ts.Node): boolean {
1240
- if (!ts.isCallExpression(node)) return false
1241
- const callee = node.expression
1242
- if (ts.isIdentifier(callee)) return STATEFUL_CALLS.has(callee.text)
1324
+ function isStatefulCall(node: N): boolean {
1325
+ if (node.type !== 'CallExpression') return false
1326
+ const callee = node.callee
1327
+ if (callee?.type === 'Identifier') return STATEFUL_CALLS.has(callee.name)
1243
1328
  return false
1244
1329
  }
1245
1330
 
1246
- /**
1247
- * Detect if an expression accesses `.children` — these can contain VNodes
1248
- * and must use _mountSlot instead of text node binding in templates.
1249
- * Matches: props.children, own.children, x.children, or bare `children` identifier.
1250
- */
1251
- function isChildrenExpression(node: ts.Expression, expr: string): boolean {
1252
- // Direct property access: props.children, own.children
1253
- if (ts.isPropertyAccessExpression(node) && node.name.text === 'children') return true
1254
- // Bare identifier named 'children'
1255
- if (ts.isIdentifier(node) && node.text === 'children') return true
1256
- // String fallback for inlined expressions
1331
+ /** Check if a call expression creates a callable reactive value (`signal(...)` or `computed(...)`). */
1332
+ function isSignalCall(node: N): boolean {
1333
+ if (node.type !== 'CallExpression') return false
1334
+ const callee = node.callee
1335
+ return callee?.type === 'Identifier' && (callee.name === 'signal' || callee.name === 'computed')
1336
+ }
1337
+
1338
+ function isChildrenExpression(node: N, expr: string): boolean {
1339
+ if (node.type === 'MemberExpression' && !node.computed && node.property?.type === 'Identifier' && node.property.name === 'children') return true
1340
+ if (node.type === 'Identifier' && node.name === 'children') return true
1257
1341
  if (expr.endsWith('.children') || expr === 'children') return true
1258
1342
  return false
1259
1343
  }
@@ -1262,11 +1346,14 @@ function isLowerCase(s: string): boolean {
1262
1346
  return s.length > 0 && s[0] === s[0]?.toLowerCase()
1263
1347
  }
1264
1348
 
1265
- /** Check if an expression subtree contains JSX nodes */
1266
- function containsJSXInExpr(node: ts.Node): boolean {
1267
- if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node))
1268
- return true
1269
- return ts.forEachChild(node, containsJSXInExpr) ?? false
1349
+ function containsJSXInExpr(node: N): boolean {
1350
+ if (node.type === 'JSXElement' || node.type === 'JSXFragment') return true
1351
+ let found = false
1352
+ forEachChild(node, (child) => {
1353
+ if (found) return
1354
+ if (containsJSXInExpr(child)) found = true
1355
+ })
1356
+ return found
1270
1357
  }
1271
1358
 
1272
1359
  function escapeHtmlAttr(s: string): string {
@@ -1274,71 +1361,57 @@ function escapeHtmlAttr(s: string): string {
1274
1361
  }
1275
1362
 
1276
1363
  function escapeHtmlText(s: string): string {
1277
- // TypeScript's JsxText preserves HTML entities as-is (e.g. "&lt;" stays "&lt;",
1278
- // not decoded to "<"). Since the template is parsed via innerHTML, entities are
1279
- // already valid HTML — pass them through. Only escape raw `<` and raw `&` that
1280
- // are NOT part of existing entities.
1281
1364
  return s.replace(/&(?!(?:#\d+|#x[\da-fA-F]+|[a-zA-Z]\w*);)/g, '&amp;').replace(/</g, '&lt;')
1282
1365
  }
1283
1366
 
1284
- // ─── Static JSX analysis ──────────────────────────────────────────────────────
1285
-
1286
- type StaticJSXNode = ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment
1287
-
1288
- function isStaticJSXNode(node: StaticJSXNode): boolean {
1289
- if (ts.isJsxSelfClosingElement(node)) {
1290
- return isStaticAttrs(node.attributes)
1367
+ function isStaticJSXNode(node: N): boolean {
1368
+ if (node.type === 'JSXElement' && node.openingElement?.selfClosing) {
1369
+ return isStaticAttrs(node.openingElement.attributes ?? [])
1291
1370
  }
1292
- if (ts.isJsxFragment(node)) {
1293
- return node.children.every(isStaticChild)
1371
+ if (node.type === 'JSXFragment') {
1372
+ return (node.children ?? []).every(isStaticChild)
1294
1373
  }
1295
- // JsxElement
1296
- return isStaticAttrs(node.openingElement.attributes) && node.children.every(isStaticChild)
1374
+ if (node.type === 'JSXElement') {
1375
+ return isStaticAttrs(node.openingElement?.attributes ?? []) && (node.children ?? []).every(isStaticChild)
1376
+ }
1377
+ return false
1297
1378
  }
1298
1379
 
1299
- function isStaticAttrs(attrs: ts.JsxAttributes): boolean {
1300
- return attrs.properties.every((prop) => {
1301
- // Spread attribute always dynamic
1302
- if (!ts.isJsxAttribute(prop)) return false
1303
- // Boolean shorthand: <input disabled />
1304
- if (!prop.initializer) return true
1305
- // String literal: class="foo"
1306
- if (ts.isStringLiteral(prop.initializer)) return true
1307
- // Must be JsxExpression — the only remaining JsxAttributeValue type
1308
- const expr = (prop.initializer as ts.JsxExpression).expression
1309
- return expr ? isStatic(expr) : true
1380
+ function isStaticAttrs(attrs: N[]): boolean {
1381
+ return attrs.every((prop: N) => {
1382
+ if (prop.type !== 'JSXAttribute') return false
1383
+ if (!prop.value) return true
1384
+ if (prop.value.type === 'StringLiteral' || (prop.value.type === 'Literal' && typeof prop.value.value === 'string')) return true
1385
+ if (prop.value.type === 'JSXExpressionContainer') {
1386
+ const expr = prop.value.expression
1387
+ if (!expr || expr.type === 'JSXEmptyExpression') return true
1388
+ return isStatic(expr)
1389
+ }
1390
+ return false
1310
1391
  })
1311
1392
  }
1312
1393
 
1313
- function isStaticChild(child: ts.JsxChild): boolean {
1314
- // Plain text content
1315
- if (ts.isJsxText(child)) return true
1316
- // Nested JSX elements
1317
- if (ts.isJsxSelfClosingElement(child)) return isStaticJSXNode(child)
1318
- if (ts.isJsxElement(child)) return isStaticJSXNode(child)
1319
- if (ts.isJsxFragment(child)) return isStaticJSXNode(child)
1320
- // Must be JsxExpression — the only remaining JsxChild type
1321
- const expr = (child as ts.JsxExpression).expression
1322
- return expr ? isStatic(expr) : true
1394
+ function isStaticChild(child: N): boolean {
1395
+ if (child.type === 'JSXText') return true
1396
+ if (child.type === 'JSXElement') return isStaticJSXNode(child)
1397
+ if (child.type === 'JSXFragment') return isStaticJSXNode(child)
1398
+ if (child.type === 'JSXExpressionContainer') {
1399
+ const expr = child.expression
1400
+ if (!expr || expr.type === 'JSXEmptyExpression') return true
1401
+ return isStatic(expr)
1402
+ }
1403
+ return false
1323
1404
  }
1324
1405
 
1325
- // ─── General helpers ──────────────────────────────────────────────────────────
1326
-
1327
- function isStatic(node: ts.Expression): boolean {
1328
- return (
1329
- ts.isStringLiteral(node) ||
1330
- ts.isNumericLiteral(node) ||
1331
- ts.isNoSubstitutionTemplateLiteral(node) ||
1332
- node.kind === ts.SyntaxKind.TrueKeyword ||
1333
- node.kind === ts.SyntaxKind.FalseKeyword ||
1334
- node.kind === ts.SyntaxKind.NullKeyword ||
1335
- node.kind === ts.SyntaxKind.UndefinedKeyword
1336
- )
1337
- // Note: object/array literals are NOT static — they need runtime application
1338
- // (e.g., style={{ color: "red" }} requires Object.assign at runtime).
1406
+ function isStatic(node: N): boolean {
1407
+ if (node.type === 'Literal') return true
1408
+ if (node.type === 'StringLiteral' || node.type === 'NumericLiteral' || node.type === 'BooleanLiteral' || node.type === 'NullLiteral') return true
1409
+ if (node.type === 'TemplateLiteral' && (node.expressions?.length ?? 0) === 0) return true
1410
+ // Note: `undefined` is an Identifier in ESTree, not a keyword literal.
1411
+ // It is NOT treated as static — it goes through the dynamic attr path.
1412
+ return false
1339
1413
  }
1340
1414
 
1341
- /** Known pure global functions that don't read signals. */
1342
1415
  const PURE_CALLS = new Set([
1343
1416
  'Math.max', 'Math.min', 'Math.abs', 'Math.floor', 'Math.ceil', 'Math.round',
1344
1417
  'Math.pow', 'Math.sqrt', 'Math.random', 'Math.trunc', 'Math.sign',
@@ -1353,30 +1426,14 @@ const PURE_CALLS = new Set([
1353
1426
  'Date.now',
1354
1427
  ])
1355
1428
 
1356
- /** Check if a call expression calls a known pure function with static args. */
1357
- function isPureStaticCall(node: ts.CallExpression): boolean {
1358
- const callee = node.expression
1429
+ function isPureStaticCall(node: N): boolean {
1430
+ const callee = node.callee
1359
1431
  let name = ''
1360
-
1361
- if (ts.isIdentifier(callee)) {
1362
- name = callee.text
1363
- } else if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)) {
1364
- name = `${callee.expression.text}.${callee.name.text}`
1432
+ if (callee?.type === 'Identifier') {
1433
+ name = callee.name
1434
+ } else if (callee?.type === 'MemberExpression' && !callee.computed && callee.object?.type === 'Identifier' && callee.property?.type === 'Identifier') {
1435
+ name = `${callee.object.name}.${callee.property.name}`
1365
1436
  }
1366
-
1367
1437
  if (!PURE_CALLS.has(name)) return false
1368
- // Pure call with all static arguments result is static
1369
- return node.arguments.every((arg) => !ts.isSpreadElement(arg) && isStatic(arg))
1370
- }
1371
-
1372
- function containsCall(node: ts.Node): boolean {
1373
- if (ts.isCallExpression(node)) {
1374
- // Skip pure calls with static args
1375
- if (isPureStaticCall(node as ts.CallExpression)) return false
1376
- return true
1377
- }
1378
- if (ts.isTaggedTemplateExpression(node)) return true
1379
- // Don't recurse into nested functions — they're self-contained
1380
- if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return false
1381
- return ts.forEachChild(node, containsCall) ?? false
1382
- }
1438
+ return (node.arguments ?? []).every((arg: N) => arg.type !== 'SpreadElement' && isStatic(arg))
1439
+ }