@pyreon/compiler 0.13.1 → 0.15.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,43 @@
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
+ import { REACT_EVENT_REMAP } from './event-names'
36
+
37
+ // ─── Native binary auto-detection ────────────────────────────────────────────
38
+ // Try to load the Rust napi-rs binary for 3.7-8.2x faster transforms.
39
+ // Falls back to the JS implementation below if the binary isn't available
40
+ // (wrong platform, CI environment, WASM runtime like StackBlitz, etc.)
41
+ //
42
+ // Uses createRequire for ESM compatibility — __dirname and require() don't
43
+ // exist in ESM modules.
44
+ type NativeTransformFn = (code: string, filename: string, ssr: boolean, knownSignals: string[] | null) => TransformResult
45
+ let nativeTransformJsx: NativeTransformFn | null = null
46
+
47
+ try {
48
+ const __filename = fileURLToPath(import.meta.url)
49
+ const __dirname = dirname(__filename)
50
+ const nativeRequire = createRequire(import.meta.url)
51
+ const nativePath = join(__dirname, '..', 'native', 'pyreon-compiler.node')
52
+ const native = nativeRequire(nativePath) as { transformJsx: NativeTransformFn }
53
+ nativeTransformJsx = native.transformJsx
54
+ } catch {
55
+ // Native binary not available — JS fallback will be used
56
+ }
37
57
 
38
58
  export interface CompilerWarning {
39
59
  /** Warning message */
@@ -65,66 +85,196 @@ const SKIP_PROPS = new Set(['key', 'ref'])
65
85
  const EVENT_RE = /^on[A-Z]/
66
86
  // Events delegated to the container — must match runtime DELEGATED_EVENTS set
67
87
  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',
88
+ 'click', 'dblclick', 'contextmenu', 'focusin', 'focusout', 'input',
89
+ 'change', 'keydown', 'keyup', 'mousedown', 'mouseup', 'mousemove',
90
+ 'mouseover', 'mouseout', 'pointerdown', 'pointerup', 'pointermove',
91
+ 'pointerover', 'pointerout', 'touchstart', 'touchend', 'touchmove',
90
92
  'submit',
91
93
  ])
92
94
 
93
95
  export interface TransformOptions {
94
96
  /**
95
97
  * 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.
98
+ * `_tpl()` template optimization and falls back to plain `h()` calls so
99
+ * `@pyreon/runtime-server` can walk the VNode tree. Default: false.
100
100
  */
101
101
  ssr?: boolean
102
+
103
+ /**
104
+ * Known signal variable names from resolved imports.
105
+ * The Vite plugin maintains a cross-module signal export registry and
106
+ * passes imported signal names here so the compiler can auto-call them
107
+ * in JSX even though the `signal()` declaration is in another file.
108
+ *
109
+ * @example
110
+ * // store.ts: export const count = signal(0)
111
+ * // component.tsx: import { count } from './store'
112
+ * transformJSX(code, 'component.tsx', { knownSignals: ['count'] })
113
+ * // {count} in JSX → {() => count()}
114
+ */
115
+ knownSignals?: string[]
116
+ }
117
+
118
+ // ─── oxc ESTree helpers ───────────────────────────────────────────────────────
119
+
120
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
121
+ type N = any // ESTree node — untyped for speed, matches the lint package approach
122
+
123
+ function getLang(filename: string): 'tsx' | 'jsx' {
124
+ if (filename.endsWith('.jsx')) return 'jsx'
125
+ // Default to tsx so JSX is always parsed — matches the original TypeScript
126
+ // parser behavior which forced ScriptKind.TSX for all files.
127
+ return 'tsx'
102
128
  }
103
129
 
130
+ /** Binary search for line/column from byte offset. */
131
+ function makeLineIndex(code: string): (offset: number) => { line: number; column: number } {
132
+ const lineStarts = [0]
133
+ for (let i = 0; i < code.length; i++) {
134
+ if (code[i] === '\n') lineStarts.push(i + 1)
135
+ }
136
+ return (offset: number) => {
137
+ let lo = 0
138
+ let hi = lineStarts.length - 1
139
+ while (lo <= hi) {
140
+ const mid = (lo + hi) >>> 1
141
+ if (lineStarts[mid]! <= offset) lo = mid + 1
142
+ else hi = mid - 1
143
+ }
144
+ return { line: lo, column: offset - lineStarts[lo - 1]! }
145
+ }
146
+ }
147
+
148
+ /** Iterate all direct children of an ESTree node via known property keys. */
149
+ function forEachChild(node: N, cb: (child: N) => void): void {
150
+ if (!node || typeof node !== 'object') return
151
+ const keys = Object.keys(node)
152
+ for (let i = 0; i < keys.length; i++) {
153
+ const key = keys[i]!
154
+ // Skip metadata fields for speed
155
+ if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue
156
+ const val = node[key]
157
+ if (Array.isArray(val)) {
158
+ for (let j = 0; j < val.length; j++) {
159
+ const item = val[j]
160
+ if (item && typeof item === 'object' && item.type) cb(item)
161
+ }
162
+ } else if (val && typeof val === 'object' && val.type) {
163
+ cb(val)
164
+ }
165
+ }
166
+ }
167
+
168
+ // ─── JSX element helpers ────────────────────────────────────────────────────
169
+
170
+ function jsxTagName(node: N): string {
171
+ const opening = node.openingElement
172
+ if (!opening) return ''
173
+ const name = opening.name
174
+ return name?.type === 'JSXIdentifier' ? name.name : ''
175
+ }
176
+
177
+ function isSelfClosing(node: N): boolean {
178
+ return node.type === 'JSXElement' && node.openingElement?.selfClosing === true
179
+ }
180
+
181
+ function jsxAttrs(node: N): N[] {
182
+ return node.openingElement?.attributes ?? []
183
+ }
184
+
185
+ function jsxChildren(node: N): N[] {
186
+ return node.children ?? []
187
+ }
188
+
189
+ // ─── Main transform ─────────────────────────────────────────────────────────
190
+
104
191
  export function transformJSX(
105
192
  code: string,
106
193
  filename = 'input.tsx',
107
194
  options: TransformOptions = {},
195
+ ): TransformResult {
196
+ // Try Rust native binary first (3.7-8.2x faster).
197
+ // Per-call try/catch: if the native binary panics on an edge case
198
+ // (bad UTF-8, unexpected AST shape), fall back gracefully instead
199
+ // of crashing the Vite dev server.
200
+ if (nativeTransformJsx) {
201
+ try {
202
+ return nativeTransformJsx(code, filename, options.ssr === true, options.knownSignals ?? null)
203
+ } catch {
204
+ // Native transform failed — fall through to JS implementation
205
+ }
206
+ }
207
+ return transformJSX_JS(code, filename, options)
208
+ }
209
+
210
+ /** JS fallback implementation — used when the native binary isn't available. */
211
+ export function transformJSX_JS(
212
+ code: string,
213
+ filename = 'input.tsx',
214
+ options: TransformOptions = {},
108
215
  ): TransformResult {
109
216
  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
217
 
113
- const sf = ts.createSourceFile(
114
- filename,
115
- code,
116
- ts.ScriptTarget.ESNext,
117
- /* setParentNodes */ true,
118
- scriptKind,
119
- )
218
+ let program: N
219
+ try {
220
+ const result = parseSync(filename, code, {
221
+ sourceType: 'module',
222
+ lang: getLang(filename),
223
+ })
224
+ program = result.program
225
+ } catch {
226
+ return { code, warnings: [] }
227
+ }
228
+
229
+ const locate = makeLineIndex(code)
120
230
 
121
231
  type Replacement = { start: number; end: number; text: string }
122
232
  const replacements: Replacement[] = []
123
233
  const warnings: CompilerWarning[] = []
124
234
 
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 })
235
+ function warn(node: N, message: string, warnCode: CompilerWarning['code']): void {
236
+ const { line, column } = locate(node.start as number)
237
+ warnings.push({ message, line, column, code: warnCode })
238
+ }
239
+
240
+ // ── Parent + children maps (built once, eliminates repeated Object.keys) ──
241
+ const parentMap = new WeakMap<object, N>()
242
+ const childrenMap = new WeakMap<object, N[]>()
243
+
244
+ /** Build parent pointers + cached children arrays for the entire AST. */
245
+ function buildMaps(node: N): void {
246
+ const kids: N[] = []
247
+ const keys = Object.keys(node)
248
+ for (let i = 0; i < keys.length; i++) {
249
+ const key = keys[i]!
250
+ if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue
251
+ const val = node[key]
252
+ if (Array.isArray(val)) {
253
+ for (let j = 0; j < val.length; j++) {
254
+ const item = val[j]
255
+ if (item && typeof item === 'object' && item.type) kids.push(item)
256
+ }
257
+ } else if (val && typeof val === 'object' && val.type) {
258
+ kids.push(val)
259
+ }
260
+ }
261
+ childrenMap.set(node, kids)
262
+ for (let i = 0; i < kids.length; i++) {
263
+ parentMap.set(kids[i]!, node)
264
+ buildMaps(kids[i]!)
265
+ }
266
+ }
267
+ buildMaps(program)
268
+
269
+ function findParent(node: N): N | undefined {
270
+ return parentMap.get(node)
271
+ }
272
+
273
+ /** Fast child iteration using pre-computed children array. */
274
+ function forEachChildFast(node: N, cb: (child: N) => void): void {
275
+ const kids = childrenMap.get(node)
276
+ if (!kids) return
277
+ for (let i = 0; i < kids.length; i++) cb(kids[i]!)
128
278
  }
129
279
 
130
280
  // ── Static hoisting state ─────────────────────────────────────────────────
@@ -139,444 +289,491 @@ export function transformJSX(
139
289
  let needsApplyPropsImportGlobal = false
140
290
  let needsMountSlotImportGlobal = false
141
291
 
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 {
292
+ function maybeHoist(node: N): string | null {
147
293
  if (
148
- (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) &&
149
- isStaticJSXNode(node as ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment)
294
+ (node.type === 'JSXElement' || node.type === 'JSXFragment') &&
295
+ isStaticJSXNode(node)
150
296
  ) {
151
297
  const name = `_$h${hoistIdx++}`
152
- const text = code.slice(node.getStart(sf), node.getEnd())
298
+ const text = code.slice(node.start as number, node.end as number)
153
299
  hoists.push({ name, text })
154
300
  return name
155
301
  }
156
302
  return null
157
303
  }
158
304
 
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 `() => ({ ... })`.
305
+ function wrap(expr: N): void {
306
+ const start = expr.start as number
307
+ const end = expr.end as number
164
308
  const sliced = sliceExpr(expr)
165
- const text = ts.isObjectLiteralExpression(expr)
309
+ const text = expr.type === 'ObjectExpression'
166
310
  ? `() => (${sliced})`
167
311
  : `() => ${sliced}`
168
312
  replacements.push({ start, end, text })
169
313
  }
170
314
 
171
- /** Try to hoist or wrap an expression, pushing a replacement if needed. */
172
- function hoistOrWrap(expr: ts.Expression): void {
315
+ function hoistOrWrap(expr: N): void {
173
316
  const hoistName = maybeHoist(expr)
174
317
  if (hoistName) {
175
- replacements.push({ start: expr.getStart(sf), end: expr.getEnd(), text: hoistName })
318
+ replacements.push({ start: expr.start as number, end: expr.end as number, text: hoistName })
176
319
  } else if (shouldWrap(expr)) {
177
320
  wrap(expr)
178
321
  }
179
322
  }
180
323
 
181
- // ── walk sub-handlers ───────────────────────────────────────────────────────
324
+ // ── Template emit ─────────────────────────────────────────────────────────
182
325
 
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.
326
+ function tryTemplateEmit(node: N): boolean {
191
327
  if (ssr) return false
192
- const elemCount = templateElementCount(node, /* isRoot */ true)
328
+ if (isSelfClosing(node)) return false
329
+ const elemCount = templateElementCount(node, true)
193
330
  if (elemCount < 1) return false
194
331
  const tplCall = buildTemplateCall(node)
195
332
  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))
333
+ const start = node.start as number
334
+ const end = node.end as number
335
+ const parent = findParent(node)
336
+ const needsBraces = parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment')
200
337
  replacements.push({ start, end, text: needsBraces ? `{${tplCall}}` : tplCall })
201
338
  needsTplImport = true
202
339
  return true
203
340
  }
204
341
 
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 : ''
342
+ function checkForWarnings(node: N): void {
343
+ const tagName = jsxTagName(node)
209
344
  if (tagName !== 'For') return
210
- const hasBy = opening.attributes.properties.some(
211
- (p) => ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text === 'by',
345
+ const hasBy = jsxAttrs(node).some(
346
+ (p: N) => p.type === 'JSXAttribute' && p.name?.type === 'JSXIdentifier' && p.name.name === 'by',
212
347
  )
213
348
  if (!hasBy) {
214
349
  warn(
215
- opening.tagName,
350
+ node.openingElement?.name ?? node,
216
351
  `<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
352
  'missing-key-on-for',
218
353
  )
219
354
  }
220
355
  }
221
356
 
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 : ''
357
+ function handleJsxAttribute(node: N, parentElement: N): void {
358
+ const name = node.name?.type === 'JSXIdentifier' ? node.name.name : ''
234
359
  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
360
+ if (!node.value || node.value.type !== 'JSXExpressionContainer') return
361
+ const expr = node.value.expression
362
+ if (!expr || expr.type === 'JSXEmptyExpression') return
238
363
 
239
- const openingEl = node.parent.parent as ts.JsxOpeningElement | ts.JsxSelfClosingElement
240
- const tagName = ts.isIdentifier(openingEl.tagName) ? openingEl.tagName.text : ''
364
+ const tagName = jsxTagName(parentElement)
241
365
  const isComponent = tagName.length > 0 && tagName.charAt(0) !== tagName.charAt(0).toLowerCase()
242
366
 
243
367
  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)
368
+ const isSingleJsx = expr.type === 'JSXElement' || expr.type === 'JSXFragment'
252
369
  if (isSingleJsx) {
253
- // Don't wrap — recurse into the JSX element's attributes instead
254
- ts.forEachChild(expr, walk)
370
+ walkNode(expr)
255
371
  return
256
372
  }
257
-
258
373
  const hoistName = maybeHoist(expr)
259
374
  if (hoistName) {
260
- replacements.push({ start: expr.getStart(sf), end: expr.getEnd(), text: hoistName })
375
+ replacements.push({ start: expr.start as number, end: expr.end as number, text: hoistName })
261
376
  } 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
377
+ const start = expr.start as number
378
+ const end = expr.end as number
265
379
  const sliced = sliceExpr(expr)
266
- const inner = ts.isObjectLiteralExpression(expr) ? `(${sliced})` : sliced
380
+ const inner = expr.type === 'ObjectExpression' ? `(${sliced})` : sliced
267
381
  replacements.push({ start, end, text: `_rp(() => ${inner})` })
268
382
  needsRpImport = true
269
383
  }
270
384
  } else {
271
- // DOM prop: standard () => expr wrapping
272
385
  hoistOrWrap(expr)
273
386
  }
274
387
  }
275
388
 
276
- /** Handle a JSX expression in child position — wrap, hoist, or recurse. */
277
- function handleJsxExpression(node: ts.JsxExpression): void {
389
+ function handleJsxExpression(node: N): void {
278
390
  const expr = node.expression
279
- if (!expr) return
391
+ if (!expr || expr.type === 'JSXEmptyExpression') return
280
392
  const hoistName = maybeHoist(expr)
281
393
  if (hoistName) {
282
- replacements.push({ start: expr.getStart(sf), end: expr.getEnd(), text: hoistName })
394
+ replacements.push({ start: expr.start as number, end: expr.end as number, text: hoistName })
283
395
  return
284
396
  }
285
397
  if (shouldWrap(expr)) {
286
398
  wrap(expr)
287
399
  return
288
400
  }
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)
401
+ walkNode(expr)
293
402
  }
294
403
 
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. */
404
+ // ── Prop-derived variable tracking (collected during the single walk) ─────
306
405
  const propsNames = new Set<string>()
406
+ const propDerivedVars = new Map<string, { start: number; end: number }>()
407
+
408
+ // ── Signal variable tracking (for auto-call in JSX) ──────────────────────
409
+ // Tracks `const x = signal(...)` declarations. In JSX expressions, bare
410
+ // references to these identifiers are auto-called: `{x}` → `{x()}`.
411
+ // This makes signals look like plain JS variables in templates while
412
+ // maintaining fine-grained reactivity.
413
+ const signalVars = new Set<string>(options.knownSignals)
414
+
415
+ // ── Scope-aware signal shadowing ──────────────────────────────────────────
416
+ // When a function/block declares a variable with the same name as a signal
417
+ // (e.g. `const show = 'text'` shadowing module-scope `const show = signal(false)`),
418
+ // that name is NOT a signal within that scope. The shadowedSignals set tracks
419
+ // names that are currently shadowed by a closer non-signal declaration.
420
+ const shadowedSignals = new Set<string>()
421
+
422
+ /** Check if an identifier name is an active (non-shadowed) signal variable. */
423
+ function isActiveSignal(name: string): boolean {
424
+ return signalVars.has(name) && !shadowedSignals.has(name)
425
+ }
307
426
 
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)
427
+ /** Find variable declarations and parameters in a function that shadow signal names. */
428
+ function findShadowingNames(node: N): string[] {
429
+ const shadows: string[] = []
430
+ // Check function parameters
431
+ for (const param of node.params ?? []) {
432
+ if (param.type === 'Identifier' && signalVars.has(param.name)) {
433
+ shadows.push(param.name)
434
+ }
435
+ // Handle destructured parameters: ({ name }) => ...
436
+ if (param.type === 'ObjectPattern') {
437
+ for (const prop of param.properties ?? []) {
438
+ const val = prop.value ?? prop.key
439
+ if (val?.type === 'Identifier' && signalVars.has(val.name)) {
440
+ shadows.push(val.name)
441
+ }
442
+ }
443
+ }
444
+ // Handle array destructured parameters: ([a, b]) => ...
445
+ if (param.type === 'ArrayPattern') {
446
+ for (const el of param.elements ?? []) {
447
+ if (el?.type === 'Identifier' && signalVars.has(el.name)) {
448
+ shadows.push(el.name)
449
+ }
450
+ }
451
+ }
316
452
  }
317
- if (ts.isElementAccessExpression(node) && ts.isIdentifier(node.expression)) {
318
- return propsNames.has(node.expression.text)
453
+ // Check top-level variable declarations in the function body
454
+ const body = node.body
455
+ const stmts = body?.body ?? body?.statements
456
+ if (!Array.isArray(stmts)) return shadows
457
+ for (const stmt of stmts) {
458
+ if (stmt.type === 'VariableDeclaration') {
459
+ for (const decl of stmt.declarations ?? []) {
460
+ if (decl.id?.type === 'Identifier' && signalVars.has(decl.id.name)) {
461
+ // Only shadow if it's NOT a signal() call
462
+ if (!decl.init || !isSignalCall(decl.init)) {
463
+ shadows.push(decl.id.name)
464
+ }
465
+ }
466
+ }
467
+ }
468
+ }
469
+ return shadows
470
+ }
471
+
472
+ function readsFromProps(node: N): boolean {
473
+ if (node.type === 'MemberExpression' && node.object?.type === 'Identifier') {
474
+ if (propsNames.has(node.object.name)) return true
319
475
  }
320
- // Check children recursively — e.g. props.x ?? 'default'
321
476
  let found = false
322
- ts.forEachChild(node, (child) => {
477
+ forEachChildFast(node, (child) => {
323
478
  if (found) return
324
479
  if (readsFromProps(child)) found = true
325
480
  })
326
481
  return found
327
482
  }
328
483
 
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
- }
484
+ /** Check if an expression references any prop-derived variable. */
485
+ function referencesPropDerived(node: N): boolean {
486
+ if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
487
+ const p = findParent(node)
488
+ if (p && p.type === 'MemberExpression' && p.property === node && !p.computed) return false
489
+ return true
343
490
  }
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
- }
491
+ let found = false
492
+ forEachChildFast(node, (child) => {
493
+ if (found) return
494
+ if (referencesPropDerived(child)) found = true
495
+ })
496
+ return found
497
+ }
357
498
 
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
499
+ /** Collect prop-derived variable info from a VariableDeclaration node.
500
+ * Called inline during the single-pass walk when we encounter a declaration. */
501
+ function collectPropDerivedFromDecl(node: N, callbackDepth: number): void {
502
+ if (node.type !== 'VariableDeclaration') return
503
+ for (const decl of node.declarations ?? []) {
504
+ // splitProps: const [own, rest] = splitProps(props, [...])
505
+ if (decl.id?.type === 'ArrayPattern' && decl.init?.type === 'CallExpression') {
506
+ const callee = decl.init.callee
507
+ if (callee?.type === 'Identifier' && callee.name === 'splitProps') {
508
+ for (const el of decl.id.elements ?? []) {
509
+ if (el?.type === 'Identifier') propsNames.add(el.name)
367
510
  }
368
- ts.forEachChild(n, checkJSX)
369
- })
370
- if (hasJSX) propsNames.add(firstParam.name.text)
511
+ }
371
512
  }
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
- }
513
+ if (node.kind !== 'const') continue
514
+ if (callbackDepth > 0) continue
515
+ if (decl.id?.type === 'Identifier' && decl.init) {
516
+ if (isStatefulCall(decl.init)) {
517
+ // Track signal() declarations for auto-call in JSX
518
+ if (isSignalCall(decl.init)) signalVars.add(decl.id.name)
519
+ continue
387
520
  }
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
- }
521
+ // Direct prop read OR transitive (references another prop-derived var)
522
+ if (readsFromProps(decl.init) || referencesPropDerived(decl.init)) {
523
+ propDerivedVars.set(decl.id.name, { start: decl.init.start as number, end: decl.init.end as number })
400
524
  }
401
525
  }
402
526
  }
403
-
404
- ts.forEachChild(node, scanForPropDerivedVars)
405
527
  }
406
528
 
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
529
+ /** Detect component functions and register their first param as a props name.
530
+ * Called inline during the walk when entering a function. */
531
+ function maybeRegisterComponentProps(node: N): void {
532
+ if (
533
+ (node.type === 'FunctionDeclaration' || node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') &&
534
+ (node.params?.length ?? 0) > 0
535
+ ) {
536
+ const parent = findParent(node)
537
+ // Skip callback functions (arguments to calls like .map, .filter)
538
+ if (parent && parent.type === 'CallExpression' && (parent.arguments ?? []).includes(node)) return
539
+ const firstParam = node.params[0]
540
+ if (firstParam?.type === 'Identifier') {
541
+ let hasJSX = false
542
+ function checkJSX(n: N): void {
543
+ if (hasJSX) return
544
+ if (n.type === 'JSXElement' || n.type === 'JSXFragment') { hasJSX = true; return }
545
+ forEachChildFast(n, checkJSX)
436
546
  }
547
+ forEachChildFast(node, checkJSX)
548
+ if (hasJSX) propsNames.add(firstParam.name)
437
549
  }
438
- })
550
+ }
439
551
  }
440
552
 
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.
553
+ // ── String-based transitive resolution ─────────────────────────────────────
554
+ const resolvedCache = new Map<string, string>()
555
+ const resolving = new Set<string>()
454
556
  const warnedCycles = new Set<string>()
455
557
 
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
- }
558
+ function resolveVarToString(varName: string, sourceNode?: N): string {
559
+ if (resolvedCache.has(varName)) return resolvedCache.get(varName)!
560
+ if (resolving.has(varName)) {
561
+ const cycleKey = [...resolving, varName].sort().join(',')
562
+ if (!warnedCycles.has(cycleKey)) {
563
+ warnedCycles.add(cycleKey)
564
+ const chain = [...resolving, varName].join(' ')
565
+ warn(
566
+ sourceNode ?? program,
567
+ `[Pyreon] Circular prop-derived const reference: ${chain}. ` +
568
+ `The cyclic identifier \`${varName}\` will use its captured value ` +
569
+ `instead of being reactively inlined. Break the cycle by reading ` +
570
+ `from \`props.*\` directly or restructuring the derivation chain.`,
571
+ 'circular-prop-derived',
572
+ )
573
+ }
574
+ return varName
575
+ }
576
+ resolving.add(varName)
577
+ const span = propDerivedVars.get(varName)!
578
+ const rawText = code.slice(span.start, span.end)
579
+ const resolved = resolveIdentifiersInText(rawText, span.start, sourceNode)
580
+ resolving.delete(varName)
581
+ resolvedCache.set(varName, resolved)
582
+ return resolved
583
+ }
484
584
 
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
- )
585
+ function resolveIdentifiersInText(text: string, baseOffset: number, sourceNode?: N): string {
586
+ const endOffset = baseOffset + text.length
587
+ const idents: { start: number; end: number; name: string }[] = []
588
+
589
+ // Walk the AST to find identifiers in the span, passing parent context
590
+ // to skip non-reference positions (property names, declarations, etc.)
591
+ function findIdents(node: N, parent: N | null): void {
592
+ const nodeStart = node.start as number
593
+ const nodeEnd = node.end as number
594
+ if (nodeStart >= endOffset || nodeEnd <= baseOffset) return
595
+ if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
596
+ if (parent) {
597
+ if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) { /* skip */ }
598
+ else if (parent.type === 'VariableDeclarator' && parent.id === node) { /* skip */ }
599
+ else if (parent.type === 'Property' && parent.key === node && !parent.computed) { /* skip */ }
600
+ else if (parent.type === 'Property' && parent.shorthand) { /* skip */ }
601
+ else if (nodeStart >= baseOffset && nodeEnd <= endOffset) {
602
+ idents.push({ start: nodeStart, end: nodeEnd, name: node.name })
503
603
  }
504
- return n
604
+ } else if (nodeStart >= baseOffset && nodeEnd <= endOffset) {
605
+ idents.push({ start: nodeStart, end: nodeEnd, name: node.name })
505
606
  }
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
607
  }
516
- return ts.visitEachChild(n, visit, undefined as any)
517
- }) as ts.Expression
608
+ forEachChildFast(node, (child) => findIdents(child, node))
609
+ }
610
+ findIdents(program, null)
611
+
612
+ if (idents.length === 0) return text
613
+
614
+ idents.sort((a, b) => a.start - b.start)
615
+ const parts: string[] = []
616
+ let lastPos = baseOffset
617
+ for (const id of idents) {
618
+ parts.push(code.slice(lastPos, id.start))
619
+ parts.push(`(${resolveVarToString(id.name, sourceNode)})`)
620
+ lastPos = id.end
621
+ }
622
+ parts.push(code.slice(lastPos, endOffset))
623
+ return parts.join('')
518
624
  }
519
625
 
520
- /** Print an AST expression back to source text. */
521
- const printer = ts.createPrinter({ removeComments: false })
626
+ // ── Analysis helpers with memoization (Phase 3) ────────────────────────────
627
+ // Cache results keyed by node.start (unique per node in a file).
628
+ // Eliminates redundant subtree traversals for containsCall + accessesProps.
629
+ const _isDynamicCache = new Map<number, boolean>()
630
+
631
+ /** Fused isDynamic: checks both containsCall and accessesProps in one traversal. */
632
+ function isDynamic(node: N): boolean {
633
+ const key = node.start as number
634
+ const cached = _isDynamicCache.get(key)
635
+ if (cached !== undefined) return cached
636
+ const result = _isDynamicImpl(node)
637
+ _isDynamicCache.set(key, result)
638
+ return result
639
+ }
522
640
 
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)
641
+ function _isDynamicImpl(node: N): boolean {
642
+ // Call expression (non-pure)
643
+ if (node.type === 'CallExpression') {
644
+ if (!isPureStaticCall(node)) return true
645
+ }
646
+ if (node.type === 'TaggedTemplateExpression') return true
647
+ // Props access
648
+ if (node.type === 'MemberExpression' && !node.computed && node.object?.type === 'Identifier') {
649
+ if (propsNames.has(node.object.name)) return true
650
+ }
651
+ // Prop-derived variable reference
652
+ if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
653
+ const parent = findParent(node)
654
+ if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) {
655
+ // This is a property name position, not a reference — fall through
656
+ } else {
657
+ return true
658
+ }
659
+ }
660
+ // Signal variable reference — treated as dynamic (will be auto-called)
661
+ if (node.type === 'Identifier' && isActiveSignal(node.name)) {
662
+ const parent = findParent(node)
663
+ if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) {
664
+ // Property name position — not a reference
665
+ } else if (parent && parent.type === 'CallExpression' && parent.callee === node) {
666
+ // Already being called: signal() — don't double-flag
667
+ } else {
668
+ return true
669
+ }
670
+ }
671
+ // Don't recurse into nested functions
672
+ if (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') return false
673
+ // Recurse into children
674
+ let found = false
675
+ forEachChildFast(node, (child) => {
676
+ if (found) return
677
+ if (isDynamic(child)) found = true
678
+ })
679
+ return found
531
680
  }
532
681
 
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
682
+ /** accessesProps kept for sliceExpr's quick check (does this need resolution?) */
683
+ function accessesProps(node: N): boolean {
684
+ if (node.type === 'MemberExpression' && !node.computed && node.object?.type === 'Identifier') {
685
+ if (propsNames.has(node.object.name)) return true
537
686
  }
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
687
+ if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
688
+ const parent = findParent(node)
689
+ if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return false
541
690
  return true
542
691
  }
543
692
  let found = false
544
- ts.forEachChild(node, (child) => {
693
+ forEachChildFast(node, (child) => {
545
694
  if (found) return
546
- if (ts.isArrowFunction(child) || ts.isFunctionExpression(child)) return
695
+ if (child.type === 'ArrowFunctionExpression' || child.type === 'FunctionExpression') return
547
696
  if (accessesProps(child)) found = true
548
697
  })
549
698
  return found
550
699
  }
551
700
 
552
- function shouldWrap(node: ts.Expression): boolean {
553
- if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return false
701
+ function shouldWrap(node: N): boolean {
702
+ if (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') return false
554
703
  if (isStatic(node)) return false
555
- if (ts.isCallExpression(node) && isPureStaticCall(node)) return false
704
+ if (node.type === 'CallExpression' && isPureStaticCall(node)) return false
556
705
  return isDynamic(node)
557
706
  }
558
707
 
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)
708
+ // ── Single unified walk (Phase 2) ─────────────────────────────────────────
709
+ // Merges the old 3-pass architecture (scanForPropDerivedVars + transitive
710
+ // resolution + JSX walk) into one top-down traversal. Works because `const`
711
+ // declarations have a temporal dead zone — they're always before their use.
712
+ let _callbackDepth = 0
713
+
714
+ function walkNode(node: N): void {
715
+ // ── Component function detection (was pass 1) ──
716
+ const isFunction = node.type === 'FunctionDeclaration' || node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression'
717
+ let scopeShadows: string[] | null = null
718
+ if (isFunction) {
719
+ // Track callback nesting for prop-derived var exclusion
720
+ const parent = findParent(node)
721
+ const isCallbackArg = parent && parent.type === 'CallExpression' && (parent.arguments ?? []).includes(node)
722
+ if (isCallbackArg) _callbackDepth++
723
+ // Register component props (only for non-callback functions with JSX)
724
+ maybeRegisterComponentProps(node)
725
+ // Track signal name shadowing for scope awareness
726
+ if (signalVars.size > 0) {
727
+ scopeShadows = findShadowingNames(node)
728
+ for (const name of scopeShadows) shadowedSignals.add(name)
729
+ }
730
+ }
731
+
732
+ // ── Variable declaration collection (was pass 1 + 2) ──
733
+ if (node.type === 'VariableDeclaration') {
734
+ collectPropDerivedFromDecl(node, _callbackDepth)
735
+ }
736
+
737
+ // ── JSX processing (was pass 3) ──
738
+ if (node.type === 'JSXElement') {
739
+ if (!isSelfClosing(node) && tryTemplateEmit(node)) {
740
+ // Template emitted — don't recurse into this subtree (JSXElement is never a function)
741
+ return
742
+ }
743
+ checkForWarnings(node)
744
+ for (const attr of jsxAttrs(node)) {
745
+ if (attr.type === 'JSXAttribute') handleJsxAttribute(attr, node)
746
+ }
747
+ for (const child of jsxChildren(node)) {
748
+ if (child.type === 'JSXExpressionContainer') handleJsxExpression(child)
749
+ else walkNode(child)
750
+ }
751
+ // Note: JSXElement is never a function, so no callback depth or scope cleanup needed here
564
752
  return
565
753
  }
566
- if (ts.isJsxExpression(node)) {
754
+ if (node.type === 'JSXExpressionContainer') {
567
755
  handleJsxExpression(node)
756
+ // Note: JSXExpressionContainer is never a function, no scope cleanup needed
568
757
  return
569
758
  }
570
- ts.forEachChild(node, walk)
759
+
760
+ // Generic descent
761
+ forEachChildFast(node, walkNode)
762
+
763
+ // Restore callback depth after leaving function
764
+ if (isFunction) {
765
+ const parent = findParent(node)
766
+ if (parent && parent.type === 'CallExpression' && (parent.arguments ?? []).includes(node)) _callbackDepth--
767
+ }
768
+ // Restore signal shadowing
769
+ if (scopeShadows) for (const name of scopeShadows) shadowedSignals.delete(name)
571
770
  }
572
771
 
573
- walk(sf)
772
+ walkNode(program)
574
773
 
575
774
  if (replacements.length === 0 && hoists.length === 0) return { code, warnings }
576
775
 
577
- // Apply replacements left-to-right via string builder — O(n) single join
578
776
  replacements.sort((a, b) => a.start - b.start)
579
-
580
777
  const parts: string[] = []
581
778
  let lastPos = 0
582
779
  for (const r of replacements) {
@@ -587,13 +784,11 @@ export function transformJSX(
587
784
  parts.push(code.slice(lastPos))
588
785
  let result = parts.join('')
589
786
 
590
- // Prepend module-scope hoisted static VNode declarations
591
787
  if (hoists.length > 0) {
592
788
  const preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join('')
593
789
  result = preamble + result
594
790
  }
595
791
 
596
- // Prepend template imports if _tpl() was emitted
597
792
  if (needsTplImport) {
598
793
  const runtimeDomImports = ['_tpl']
599
794
  if (needsBindDirectImportGlobal) runtimeDomImports.push('_bindDirect')
@@ -608,65 +803,45 @@ export function transformJSX(
608
803
  result
609
804
  }
610
805
 
611
- // Prepend _rp import if reactive component props were emitted
612
806
  if (needsRpImport) {
613
807
  result = `import { _rp } from "@pyreon/core";\n` + result
614
808
  }
615
809
 
616
810
  return { code: result, usesTemplates: needsTplImport, warnings }
617
811
 
618
- // ── Template emission helpers (closures over sf, code) ──────────────────────
812
+ // ── Template emission helpers ─────────────────────────────────────────────
619
813
 
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 {
814
+ function hasBailAttr(node: N, isRoot = false): boolean {
627
815
  for (const attr of jsxAttrs(node)) {
628
- if (ts.isJsxSpreadAttribute(attr)) {
629
- // Allow spread on root element — handled in buildTemplateCall
816
+ if (attr.type === 'JSXSpreadAttribute') {
630
817
  if (isRoot) continue
631
818
  return true
632
819
  }
633
- if (ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name) && attr.name.text === 'key')
820
+ if (attr.type === 'JSXAttribute' && attr.name?.type === 'JSXIdentifier' && attr.name.name === 'key')
634
821
  return true
635
822
  }
636
823
  return false
637
824
  }
638
825
 
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)
826
+ function countChildForTemplate(child: N): number {
827
+ if (child.type === 'JSXText') return 0
828
+ if (child.type === 'JSXElement') return templateElementCount(child)
829
+ if (child.type === 'JSXExpressionContainer') {
830
+ const expr = child.expression
831
+ if (!expr || expr.type === 'JSXEmptyExpression') return 0
832
+ return containsJSXInExpr(expr) ? -1 : 0
833
+ }
834
+ if (child.type === 'JSXFragment') return templateFragmentCount(child)
652
835
  return -1
653
836
  }
654
837
 
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 {
838
+ function templateElementCount(node: N, isRoot = false): number {
663
839
  const tag = jsxTagName(node)
664
840
  if (!tag || !isLowerCase(tag)) return -1
665
841
  if (hasBailAttr(node, isRoot)) return -1
666
- if (!ts.isJsxElement(node)) return 1
667
-
842
+ if (isSelfClosing(node)) return 1
668
843
  let count = 1
669
- for (const child of node.children) {
844
+ for (const child of jsxChildren(node)) {
670
845
  const c = countChildForTemplate(child)
671
846
  if (c === -1) return -1
672
847
  count += c
@@ -674,10 +849,9 @@ export function transformJSX(
674
849
  return count
675
850
  }
676
851
 
677
- /** Count template-eligible elements inside a fragment. */
678
- function templateFragmentCount(frag: ts.JsxFragment): number {
852
+ function templateFragmentCount(frag: N): number {
679
853
  let count = 0
680
- for (const child of frag.children) {
854
+ for (const child of jsxChildren(frag)) {
681
855
  const c = countChildForTemplate(child)
682
856
  if (c === -1) return -1
683
857
  count += c
@@ -685,35 +859,25 @@ export function transformJSX(
685
859
  return count
686
860
  }
687
861
 
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 {
862
+ function buildTemplateCall(node: N): string | null {
693
863
  const bindLines: string[] = []
694
864
  const disposerNames: string[] = []
695
865
  let varIdx = 0
696
866
  let dispIdx = 0
697
- // Reactive expressions that will be combined into a single _bind call
698
867
  const reactiveBindExprs: string[] = []
699
868
  let needsBindTextImport = false
700
869
  let needsBindDirectImport = false
701
870
  let needsApplyPropsImport = false
702
871
  let needsMountSlotImport = false
703
872
 
704
- function nextVar(): string {
705
- return `__e${varIdx++}`
706
- }
873
+ function nextVar(): string { return `__e${varIdx++}` }
707
874
  function nextDisp(): string {
708
875
  const name = `__d${dispIdx++}`
709
876
  disposerNames.push(name)
710
877
  return name
711
878
  }
712
- function nextTextVar(): string {
713
- return `__t${varIdx++}`
714
- }
879
+ function nextTextVar(): string { return `__t${varIdx++}` }
715
880
 
716
- /** Resolve the variable name for an element given its accessor path. */
717
881
  function resolveElementVar(accessor: string, hasDynamic: boolean): string {
718
882
  if (accessor === '__root') return '__root'
719
883
  if (hasDynamic) {
@@ -724,14 +888,11 @@ export function transformJSX(
724
888
  return accessor
725
889
  }
726
890
 
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)) {
891
+ function emitRef(attr: N, varName: string): void {
892
+ if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return
893
+ const expr = attr.value.expression
894
+ if (!expr || expr.type === 'JSXEmptyExpression') return
895
+ if (expr.type === 'ArrowFunctionExpression' || expr.type === 'FunctionExpression') {
735
896
  bindLines.push(`(${sliceExpr(expr)})(${varName})`)
736
897
  } else {
737
898
  bindLines.push(
@@ -740,87 +901,87 @@ export function transformJSX(
740
901
  }
741
902
  }
742
903
 
743
- /** Emit event handler bind line — delegated (expando) or addEventListener. */
744
- function emitEventListener(attr: ts.JsxAttribute, attrName: string, varName: string): void {
745
- 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)
904
+ function emitEventListener(attr: N, attrName: string, varName: string): void {
905
+ // Translate the JSX-style React attribute name (e.g. `onKeyDown`,
906
+ // `onDoubleClick`) to the canonical DOM event name (`keydown`,
907
+ // `dblclick`).
908
+ //
909
+ // The default rule is "drop the `on` prefix and lowercase" —
910
+ // covers `onKeyDown` → `keydown`, `onMouseEnter` → `mouseenter`,
911
+ // `onPointerLeave` → `pointerleave`, `onAnimationStart` →
912
+ // `animationstart`, etc. Most React event names follow this rule
913
+ // because the underlying DOM event name is also the lowercased
914
+ // multi-word form.
915
+ //
916
+ // The exception list lives in `REACT_EVENT_REMAP` (event-names.ts).
917
+ // Every React event-prop in the official component-prop list was
918
+ // audited against canonical DOM event names — see the JSDoc on
919
+ // REACT_EVENT_REMAP for the audit. Today exactly one entry:
920
+ // `onDoubleClick` → `dblclick`
921
+ // The Rust native backend (`native/src/lib.rs:emit_event_listener`)
922
+ // mirrors the same table — keep them in sync if a new entry is added.
923
+ const lowered = attrName.slice(2).toLowerCase()
924
+ const eventName = REACT_EVENT_REMAP[lowered] ?? lowered
925
+ if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return
926
+ const expr = attr.value.expression
927
+ if (!expr || expr.type === 'JSXEmptyExpression') return
928
+ const handler = sliceExpr(expr)
749
929
  if (DELEGATED_EVENTS.has(eventName)) {
750
- // Delegated: store handler as expando property — container listener picks it up
751
930
  bindLines.push(`${varName}.__ev_${eventName} = ${handler}`)
752
931
  } else {
753
932
  bindLines.push(`${varName}.addEventListener("${eventName}", ${handler})`)
754
933
  }
755
934
  }
756
935
 
757
- /** Return HTML string for a static attribute expression, or null if not static. */
758
- function staticAttrToHtml(exprNode: ts.Expression, htmlAttrName: string): string | null {
936
+ function staticAttrToHtml(exprNode: N, htmlAttrName: string): string | null {
759
937
  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}`
938
+ // String literal
939
+ if ((exprNode.type === 'Literal' || exprNode.type === 'StringLiteral') && typeof exprNode.value === 'string')
940
+ return ` ${htmlAttrName}="${escapeHtmlAttr(exprNode.value)}"`
941
+ // Numeric literal
942
+ if ((exprNode.type === 'Literal' || exprNode.type === 'NumericLiteral') && typeof exprNode.value === 'number')
943
+ return ` ${htmlAttrName}="${exprNode.value}"`
944
+ // Boolean true
945
+ if ((exprNode.type === 'Literal' || exprNode.type === 'BooleanLiteral') && exprNode.value === true)
946
+ return ` ${htmlAttrName}`
763
947
  return '' // false/null/undefined → omit
764
948
  }
765
949
 
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 {
950
+ function tryDirectSignalRef(exprNode: N): string | null {
772
951
  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)
952
+ if (inner.type === 'ArrowFunctionExpression' && inner.body?.type !== 'BlockStatement') {
953
+ inner = inner.body
785
954
  }
955
+ if (inner.type !== 'CallExpression') return null
956
+ if ((inner.arguments?.length ?? 0) > 0) return null
957
+ const callee = inner.callee
958
+ if (callee?.type === 'Identifier') return sliceExpr(callee)
786
959
  return null
787
960
  }
788
961
 
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 }
962
+ function unwrapAccessor(exprNode: N): { expr: string; isReactive: boolean } {
963
+ if (exprNode.type === 'ArrowFunctionExpression' && exprNode.body?.type !== 'BlockStatement') {
964
+ return { expr: sliceExpr(exprNode.body), isReactive: true }
794
965
  }
795
- // Block-body arrow/function: invoke it
796
- if (ts.isArrowFunction(exprNode) || ts.isFunctionExpression(exprNode)) {
966
+ if (exprNode.type === 'ArrowFunctionExpression' || exprNode.type === 'FunctionExpression') {
797
967
  return { expr: `(${sliceExpr(exprNode)})()`, isReactive: true }
798
968
  }
799
969
  return { expr: sliceExpr(exprNode), isReactive: isDynamic(exprNode) }
800
970
  }
801
971
 
802
- /** Build a setter expression for an attribute. */
803
972
  function attrSetter(htmlAttrName: string, varName: string, expr: string): string {
804
973
  if (htmlAttrName === 'class') return `${varName}.className = ${expr}`
805
974
  if (htmlAttrName === 'style') return `${varName}.style.cssText = ${expr}`
975
+ if (DOM_PROPS.has(htmlAttrName)) return `${varName}.${htmlAttrName} = ${expr}`
806
976
  return `${varName}.setAttribute("${htmlAttrName}", ${expr})`
807
977
  }
808
978
 
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 {
979
+ function emitDynamicAttr(_expr: string, exprNode: N, htmlAttrName: string, varName: string): void {
816
980
  const { expr, isReactive } = unwrapAccessor(exprNode)
817
-
818
981
  if (!isReactive) {
819
982
  bindLines.push(attrSetter(htmlAttrName, varName, expr))
820
983
  return
821
984
  }
822
-
823
- // Direct signal binding for bare signal calls (e.g. class={() => active()})
824
985
  const directRef = tryDirectSignalRef(exprNode)
825
986
  if (directRef) {
826
987
  needsBindDirectImport = true
@@ -830,115 +991,85 @@ export function transformJSX(
830
991
  ? `(v) => { ${varName}.className = v == null ? "" : String(v) }`
831
992
  : htmlAttrName === 'style'
832
993
  ? `(v) => { if (typeof v === "string") ${varName}.style.cssText = v; else if (v) Object.assign(${varName}.style, v) }`
833
- : `(v) => { ${varName}.setAttribute("${htmlAttrName}", v == null ? "" : String(v)) }`
994
+ : DOM_PROPS.has(htmlAttrName)
995
+ ? `(v) => { ${varName}.${htmlAttrName} = v }`
996
+ : `(v) => { ${varName}.setAttribute("${htmlAttrName}", v == null ? "" : String(v)) }`
834
997
  bindLines.push(`const ${d} = _bindDirect(${directRef}, ${updater})`)
835
998
  return
836
999
  }
837
-
838
1000
  reactiveBindExprs.push(attrSetter(htmlAttrName, varName, expr))
839
1001
  }
840
1002
 
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 {
1003
+ function emitAttrExpression(exprNode: N, htmlAttrName: string, varName: string): string {
847
1004
  const staticHtml = staticAttrToHtml(exprNode, htmlAttrName)
848
1005
  if (staticHtml !== null) return staticHtml
849
-
850
- // style={{...}} → Object.assign(el.style, {...}) for object expressions
851
- if (htmlAttrName === 'style' && ts.isObjectLiteralExpression(exprNode)) {
1006
+ if (htmlAttrName === 'style' && exprNode.type === 'ObjectExpression') {
852
1007
  bindLines.push(`Object.assign(${varName}.style, ${sliceExpr(exprNode)})`)
853
1008
  return ''
854
1009
  }
855
-
856
1010
  emitDynamicAttr(sliceExpr(exprNode), exprNode, htmlAttrName, varName)
857
1011
  return ''
858
1012
  }
859
1013
 
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
- }
1014
+ function tryEmitSpecialAttr(attr: N, attrName: string, varName: string): boolean {
1015
+ if (attrName === 'ref') { emitRef(attr, varName); return true }
1016
+ if (EVENT_RE.test(attrName)) { emitEventListener(attr, attrName, varName); return true }
870
1017
  return false
871
1018
  }
872
1019
 
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)
1020
+ function attrInitializerToHtml(attr: N, htmlAttrName: string, varName: string): string {
1021
+ if (!attr.value) return ` ${htmlAttrName}`
1022
+ // JSX string attribute: class="foo"
1023
+ if (attr.value.type === 'StringLiteral' || (attr.value.type === 'Literal' && typeof attr.value.value === 'string'))
1024
+ return ` ${htmlAttrName}="${escapeHtmlAttr(attr.value.value)}"`
1025
+ if (attr.value.type === 'JSXExpressionContainer') {
1026
+ const expr = attr.value.expression
1027
+ if (expr && expr.type !== 'JSXEmptyExpression') return emitAttrExpression(expr, htmlAttrName, varName)
1028
+ }
884
1029
  return ''
885
1030
  }
886
1031
 
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.
1032
+ function processOneAttr(attr: N, varName: string): string {
1033
+ if (attr.type === 'JSXSpreadAttribute') {
1034
+ const expr = sliceExpr(attr.argument)
893
1035
  needsApplyPropsImport = true
894
- if (isDynamic(attr.expression)) {
1036
+ if (isDynamic(attr.argument)) {
895
1037
  reactiveBindExprs.push(`_applyProps(${varName}, ${expr})`)
896
1038
  } else {
897
1039
  bindLines.push(`_applyProps(${varName}, ${expr})`)
898
1040
  }
899
1041
  return ''
900
1042
  }
901
- if (!ts.isJsxAttribute(attr)) return ''
902
- const attrName = ts.isIdentifier(attr.name) ? attr.name.text : ''
1043
+ if (attr.type !== 'JSXAttribute') return ''
1044
+ const attrName = attr.name?.type === 'JSXIdentifier' ? attr.name.name : ''
903
1045
  if (attrName === 'key') return ''
904
1046
  if (tryEmitSpecialAttr(attr, attrName, varName)) return ''
905
1047
  return attrInitializerToHtml(attr, JSX_TO_HTML_ATTR[attrName] ?? attrName, varName)
906
1048
  }
907
1049
 
908
- /** Process all attributes on an element, returning the HTML attribute string. */
909
- function processAttrs(el: ts.JsxElement | ts.JsxSelfClosingElement, varName: string): string {
1050
+ function processAttrs(el: N, varName: string): string {
910
1051
  let htmlAttrs = ''
911
1052
  for (const attr of jsxAttrs(el)) htmlAttrs += processOneAttr(attr, varName)
912
1053
  return htmlAttrs
913
1054
  }
914
1055
 
915
- /** Emit bind lines for a reactive text expression child. */
916
1056
  function emitReactiveTextChild(
917
- expr: string,
918
- exprNode: ts.Expression,
919
- varName: string,
920
- parentRef: string,
921
- childNodeIdx: number,
922
- needsPlaceholder: boolean,
1057
+ expr: string, exprNode: N, varName: string,
1058
+ parentRef: string, childNodeIdx: number, needsPlaceholder: boolean,
923
1059
  ): string {
924
1060
  const tVar = nextTextVar()
925
1061
  bindLines.push(`const ${tVar} = document.createTextNode("")`)
926
1062
  if (needsPlaceholder) {
927
- bindLines.push(
928
- `${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`,
929
- )
1063
+ bindLines.push(`${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`)
930
1064
  } else {
931
1065
  bindLines.push(`${varName}.appendChild(${tVar})`)
932
1066
  }
933
- // Direct signal binding: bypass effect system entirely
934
1067
  const directRef = tryDirectSignalRef(exprNode)
935
1068
  if (directRef) {
936
1069
  needsBindTextImport = true
937
1070
  const d = nextDisp()
938
1071
  bindLines.push(`const ${d} = _bindText(${directRef}, ${tVar})`)
939
1072
  } 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
1073
  needsBindImportGlobal = true
943
1074
  const d = nextDisp()
944
1075
  bindLines.push(`const ${d} = _bind(() => { ${tVar}.data = ${expr} })`)
@@ -946,34 +1077,98 @@ export function transformJSX(
946
1077
  return needsPlaceholder ? '<!>' : ''
947
1078
  }
948
1079
 
949
- /** Emit bind lines for a static text expression child. */
950
1080
  function emitStaticTextChild(
951
- expr: string,
952
- varName: string,
953
- parentRef: string,
954
- childNodeIdx: number,
955
- needsPlaceholder: boolean,
1081
+ expr: string, varName: string,
1082
+ parentRef: string, childNodeIdx: number, needsPlaceholder: boolean,
956
1083
  ): string {
957
1084
  if (needsPlaceholder) {
958
1085
  const tVar = nextTextVar()
959
1086
  bindLines.push(`const ${tVar} = document.createTextNode(${expr})`)
960
- bindLines.push(
961
- `${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`,
962
- )
1087
+ bindLines.push(`${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`)
963
1088
  return '<!>'
964
1089
  }
965
1090
  bindLines.push(`${varName}.textContent = ${expr}`)
966
1091
  return ''
967
1092
  }
968
1093
 
969
- /** Process a single flat child, returning the HTML contribution or null on failure. */
1094
+ type FlatChild =
1095
+ | { kind: 'text'; text: string }
1096
+ | { kind: 'element'; node: N; elemIdx: number }
1097
+ | { kind: 'expression'; expression: N }
1098
+
1099
+ function classifyJsxChild(
1100
+ child: N, out: FlatChild[],
1101
+ elemIdxRef: { value: number },
1102
+ recurse: (kids: N[]) => void,
1103
+ ): void {
1104
+ if (child.type === 'JSXText') {
1105
+ const raw = child.value ?? child.raw ?? ''
1106
+ const cleaned = cleanJsxText(raw)
1107
+ if (cleaned) out.push({ kind: 'text', text: cleaned })
1108
+ return
1109
+ }
1110
+ if (child.type === 'JSXElement') {
1111
+ out.push({ kind: 'element', node: child, elemIdx: elemIdxRef.value++ })
1112
+ return
1113
+ }
1114
+ if (child.type === 'JSXExpressionContainer') {
1115
+ const expr = child.expression
1116
+ if (expr && expr.type !== 'JSXEmptyExpression') out.push({ kind: 'expression', expression: expr })
1117
+ return
1118
+ }
1119
+ if (child.type === 'JSXFragment') recurse(jsxChildren(child))
1120
+ }
1121
+
1122
+ function flattenChildren(children: N[]): FlatChild[] {
1123
+ const flatList: FlatChild[] = []
1124
+ const elemIdxRef = { value: 0 }
1125
+ function addChildren(kids: N[]): void {
1126
+ for (const child of kids) classifyJsxChild(child, flatList, elemIdxRef, addChildren)
1127
+ }
1128
+ addChildren(children)
1129
+ return flatList
1130
+ }
1131
+
1132
+ function analyzeChildren(flatChildren: FlatChild[]): { useMixed: boolean; useMultiExpr: boolean } {
1133
+ const hasElem = flatChildren.some((c) => c.kind === 'element')
1134
+ const hasText = flatChildren.some((c) => c.kind === 'text')
1135
+ const exprCount = flatChildren.filter((c) => c.kind === 'expression').length
1136
+ // `useMixed` triggers placeholder-based positional mounting (each
1137
+ // dynamic child gets a `<!>` comment slot in the template that
1138
+ // `replaceChild`-replaces at mount). It must fire whenever ≥2 of
1139
+ // {element, text, expression} are interleaved — otherwise dynamic
1140
+ // text nodes added via `appendChild` land after all static
1141
+ // template content, breaking source-order rendering for shapes
1142
+ // like `<p>foo {x()} bar</p>` (rendered "foo barX" instead of
1143
+ // "foo X bar"). Discovered by Phase B2's whitespace tests.
1144
+ const present =
1145
+ (hasElem ? 1 : 0) + (hasText ? 1 : 0) + (exprCount > 0 ? 1 : 0)
1146
+ return { useMixed: present > 1, useMultiExpr: exprCount > 1 }
1147
+ }
1148
+
1149
+ function attrIsDynamic(attr: N): boolean {
1150
+ if (attr.type !== 'JSXAttribute') return false
1151
+ const name = attr.name?.type === 'JSXIdentifier' ? attr.name.name : ''
1152
+ if (name === 'ref') return true
1153
+ if (EVENT_RE.test(name)) return true
1154
+ if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return false
1155
+ const expr = attr.value.expression
1156
+ return expr && expr.type !== 'JSXEmptyExpression' ? !isStatic(expr) : false
1157
+ }
1158
+
1159
+ function elementHasDynamic(node: N): boolean {
1160
+ if (jsxAttrs(node).some(attrIsDynamic)) return true
1161
+ if (!isSelfClosing(node)) {
1162
+ return jsxChildren(node).some((c: N) =>
1163
+ c.type === 'JSXExpressionContainer' && c.expression && c.expression.type !== 'JSXEmptyExpression',
1164
+ )
1165
+ }
1166
+ return false
1167
+ }
1168
+
970
1169
  function processOneChild(
971
- child: FlatChild,
972
- varName: string,
973
- parentRef: string,
974
- useMixed: boolean,
975
- useMultiExpr: boolean,
976
- childNodeIdx: number,
1170
+ child: FlatChild, varName: string, parentRef: string,
1171
+ useMixed: boolean, useMultiExpr: boolean, childNodeIdx: number,
977
1172
  ): string | null {
978
1173
  if (child.kind === 'text') return escapeHtmlText(child.text)
979
1174
  if (child.kind === 'element') {
@@ -982,12 +1177,8 @@ export function transformJSX(
982
1177
  : `${parentRef}.children[${child.elemIdx}]`
983
1178
  return processElement(child.node, childAccessor)
984
1179
  }
985
- // expression
986
1180
  const needsPlaceholder = useMixed || useMultiExpr
987
1181
  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
1182
  if (isChildrenExpression(child.expression, expr)) {
992
1183
  needsMountSlotImport = true
993
1184
  const placeholder = `${parentRef}.childNodes[${childNodeIdx}]`
@@ -995,64 +1186,38 @@ export function transformJSX(
995
1186
  bindLines.push(`const ${d} = _mountSlot(${expr}, ${parentRef}, ${placeholder})`)
996
1187
  return '<!>'
997
1188
  }
998
-
999
1189
  if (isReactive) {
1000
- return emitReactiveTextChild(
1001
- expr,
1002
- child.expression,
1003
- varName,
1004
- parentRef,
1005
- childNodeIdx,
1006
- needsPlaceholder,
1007
- )
1190
+ return emitReactiveTextChild(expr, child.expression, varName, parentRef, childNodeIdx, needsPlaceholder)
1008
1191
  }
1009
1192
  return emitStaticTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder)
1010
1193
  }
1011
1194
 
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)
1195
+ function processChildren(el: N, varName: string, accessor: string): string | null {
1196
+ const flatChildren = flattenChildren(jsxChildren(el))
1015
1197
  const { useMixed, useMultiExpr } = analyzeChildren(flatChildren)
1016
1198
  const parentRef = accessor === '__root' ? '__root' : varName
1017
-
1018
1199
  let html = ''
1019
1200
  let childNodeIdx = 0
1020
-
1021
1201
  for (const child of flatChildren) {
1022
- const childHtml = processOneChild(
1023
- child,
1024
- varName,
1025
- parentRef,
1026
- useMixed,
1027
- useMultiExpr,
1028
- childNodeIdx,
1029
- )
1202
+ const childHtml = processOneChild(child, varName, parentRef, useMixed, useMultiExpr, childNodeIdx)
1030
1203
  if (childHtml === null) return null
1031
1204
  html += childHtml
1032
1205
  childNodeIdx++
1033
1206
  }
1034
-
1035
1207
  return html
1036
1208
  }
1037
1209
 
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 {
1210
+ function processElement(el: N, accessor: string): string | null {
1043
1211
  const tag = jsxTagName(el)
1044
1212
  if (!tag) return null
1045
-
1046
1213
  const varName = resolveElementVar(accessor, elementHasDynamic(el))
1047
1214
  const htmlAttrs = processAttrs(el, varName)
1048
1215
  let html = `<${tag}${htmlAttrs}>`
1049
-
1050
- if (ts.isJsxElement(el)) {
1216
+ if (!isSelfClosing(el)) {
1051
1217
  const childHtml = processChildren(el, varName, accessor)
1052
1218
  if (childHtml === null) return null
1053
1219
  html += childHtml
1054
1220
  }
1055
-
1056
1221
  if (!VOID_ELEMENTS.has(tag)) html += `</${tag}>`
1057
1222
  return html
1058
1223
  }
@@ -1065,15 +1230,8 @@ export function transformJSX(
1065
1230
  if (needsApplyPropsImport) needsApplyPropsImportGlobal = true
1066
1231
  if (needsMountSlotImport) needsMountSlotImportGlobal = true
1067
1232
 
1068
- // Build bind function body
1069
1233
  const escaped = html.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
1070
1234
 
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
1235
  if (reactiveBindExprs.length > 0) {
1078
1236
  needsBindImportGlobal = true
1079
1237
  const combinedName = nextDisp()
@@ -1085,7 +1243,31 @@ export function transformJSX(
1085
1243
  return `_tpl("${escaped}", () => null)`
1086
1244
  }
1087
1245
 
1088
- let body = bindLines.map((l) => ` ${l}`).join('\n')
1246
+ // Append `;` to every bind line so ASI can't merge consecutive
1247
+ // statements when the next line starts with `(`, `[`, etc.
1248
+ // Concrete bug shape (pre-fix): a child element with `hasDynamic=true`
1249
+ // emits `const __e0 = __root.children[N]` followed by a ref-callback
1250
+ // line `((el) => { x = el })(__e0)`. JS does NOT insert ASI here
1251
+ // because `__root.children[N]((el) => ...)` is a valid expression,
1252
+ // so the parser merges them into a single function call:
1253
+ // `const __e0 = __root.children[N]((el) => ...)(__e0)`
1254
+ // — calling `children[N]` as a function with the arrow as argument,
1255
+ // and self-referencing `__e0` before assignment. Adding the `;`
1256
+ // terminates each statement deterministically. Trailing `;` after
1257
+ // a `{...}` block is a harmless empty statement.
1258
+ // Append `;` to every bind line so ASI can't merge consecutive
1259
+ // statements when the next line starts with `(`, `[`, etc.
1260
+ // Concrete bug shape (pre-fix): a child element with `hasDynamic=true`
1261
+ // emits `const __e0 = __root.children[N]` followed by a ref-callback
1262
+ // line `((el) => { x = el })(__e0)`. JS does NOT insert ASI here
1263
+ // because `__root.children[N]((el) => ...)` is a valid expression,
1264
+ // so the parser merges them into a single function call:
1265
+ // `const __e0 = __root.children[N]((el) => ...)(__e0)`
1266
+ // — calling `children[N]` as a function with the arrow as argument,
1267
+ // and self-referencing `__e0` before assignment. Adding the `;`
1268
+ // terminates each statement deterministically. Trailing `;` after
1269
+ // a `{...}` block is a harmless empty statement.
1270
+ let body = bindLines.map((l) => ` ${l};`).join('\n')
1089
1271
  if (disposerNames.length > 0) {
1090
1272
  body += `\n return () => { ${disposerNames.map((d) => `${d}()`).join('; ')} }`
1091
1273
  } else {
@@ -1095,127 +1277,123 @@ export function transformJSX(
1095
1277
  return `_tpl("${escaped}", (__root) => {\n${body}\n})`
1096
1278
  }
1097
1279
 
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
1280
+ function sliceExpr(expr: N): string {
1281
+ let result: string
1282
+ if (propDerivedVars.size > 0 && accessesProps(expr)) {
1283
+ const start = expr.start as number
1284
+ const end = expr.end as number
1285
+ result = resolveIdentifiersInText(code.slice(start, end), start, expr)
1286
+ } else {
1287
+ result = code.slice(expr.start as number, expr.end as number)
1123
1288
  }
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
1289
 
1135
- function addChildren(kids: ts.NodeArray<ts.JsxChild>): void {
1136
- for (const child of kids) classifyJsxChild(child, flatList, elemIdxRef, addChildren)
1290
+ // Auto-call signal variables: replace bare `x` with `x()` in the expression.
1291
+ // Only applies to identifiers that are NOT already being called (not `x()`).
1292
+ if (signalVars.size > 0 && signalVars.size > shadowedSignals.size && referencesSignalVar(expr)) {
1293
+ result = autoCallSignals(result, expr)
1137
1294
  }
1138
1295
 
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 }
1152
- }
1153
-
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
1296
+ return result
1163
1297
  }
1164
1298
 
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)
1299
+ /** Check if an expression references any tracked signal variable. */
1300
+ function referencesSignalVar(node: N): boolean {
1301
+ if (node.type === 'Identifier' && isActiveSignal(node.name)) {
1302
+ const parent = findParent(node)
1303
+ if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return false
1304
+ // signal.X(...) — operating on the signal object (calling a method).
1305
+ // Mirrors the same narrow skip in findSignalIdents below.
1306
+ if (
1307
+ parent &&
1308
+ parent.type === 'MemberExpression' &&
1309
+ parent.object === node
1310
+ ) {
1311
+ const grand = findParent(parent)
1312
+ if (grand && grand.type === 'CallExpression' && grand.callee === parent) return false
1313
+ }
1314
+ if (parent && parent.type === 'CallExpression' && parent.callee === node) return false // already called
1315
+ return true
1170
1316
  }
1171
- return false
1317
+ let found = false
1318
+ forEachChildFast(node, (child) => {
1319
+ if (found) return
1320
+ if (child.type === 'ArrowFunctionExpression' || child.type === 'FunctionExpression') return
1321
+ if (referencesSignalVar(child)) found = true
1322
+ })
1323
+ return found
1172
1324
  }
1173
1325
 
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)
1326
+ /** Auto-insert () after signal variable references in the expression source.
1327
+ * Uses the AST to find exact Identifier positions — never scans raw text. */
1328
+ function autoCallSignals(text: string, expr: N): string {
1329
+ const start = expr.start as number
1330
+ // Collect signal identifier positions that need auto-calling
1331
+ const idents: { start: number; end: number }[] = []
1332
+
1333
+ function findSignalIdents(node: N): void {
1334
+ if ((node.start as number) >= start + text.length || (node.end as number) <= start) return
1335
+ if (node.type === 'Identifier' && isActiveSignal(node.name)) {
1336
+ const parent = findParent(node)
1337
+ // Skip property name positions (obj.name)
1338
+ if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return
1339
+ // Skip when the identifier is the OBJECT of a member access AND
1340
+ // the result is being CALLED (signal.set(...), signal.peek(),
1341
+ // signal.update(...)). The user is invoking a method on the
1342
+ // signal OBJECT — auto-calling would produce `signal().set(...)`
1343
+ // which calls the signal, gets its value (string/number/etc),
1344
+ // then `.set` on the value is undefined → TypeError. Every event
1345
+ // handler that did `signal.set(x)` was silently broken.
1346
+ //
1347
+ // Note: bare `signal.value` (member access NOT followed by call)
1348
+ // STILL auto-calls — keeps the existing convention where
1349
+ // `signal({a:1})` followed by `signal.a` reads the signal's
1350
+ // value's property (see "signal as member expression object IS
1351
+ // auto-called" test).
1352
+ if (
1353
+ parent &&
1354
+ parent.type === 'MemberExpression' &&
1355
+ parent.object === node
1356
+ ) {
1357
+ const grand = findParent(parent)
1358
+ if (grand && grand.type === 'CallExpression' && grand.callee === parent) return
1359
+ }
1360
+ // Skip if already being called: signal()
1361
+ if (parent && parent.type === 'CallExpression' && parent.callee === node) return
1362
+ // Skip declaration positions
1363
+ if (parent && parent.type === 'VariableDeclarator' && parent.id === node) return
1364
+ // Skip object property keys and shorthand properties ({ name } or { name: val })
1365
+ // Inserting () after a shorthand key produces name() which is a method shorthand — invalid
1366
+ if (parent && (parent.type === 'Property' || parent.type === 'ObjectProperty')) {
1367
+ if (parent.shorthand) return // { name } — can't auto-call without breaking syntax
1368
+ if (parent.key === node && !parent.computed) return // { name: val } — key position
1369
+ }
1370
+ idents.push({ start: node.start as number, end: node.end as number })
1371
+ }
1372
+ forEachChildFast(node, findSignalIdents)
1182
1373
  }
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
1374
+ findSignalIdents(expr)
1375
+
1376
+ if (idents.length === 0) return text
1377
+
1378
+ // Sort by position and insert () after each identifier
1379
+ idents.sort((a, b) => a.start - b.start)
1380
+ const parts: string[] = []
1381
+ let lastPos = start
1382
+ for (const id of idents) {
1383
+ parts.push(code.slice(lastPos, id.end))
1384
+ parts.push('()') // auto-call
1385
+ lastPos = id.end
1386
+ }
1387
+ parts.push(code.slice(lastPos, start + text.length))
1388
+ return parts.join('')
1199
1389
  }
1200
1390
  }
1201
1391
 
1202
- // ─── Template constants ──────────────────────────────────────────────────────
1392
+ // ─── Module-scope constants and helpers ─────────────────────────────────────
1203
1393
 
1204
1394
  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',
1395
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
1396
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
1219
1397
  ])
1220
1398
 
1221
1399
  const JSX_TO_HTML_ATTR: Record<string, string> = {
@@ -1223,11 +1401,24 @@ const JSX_TO_HTML_ATTR: Record<string, string> = {
1223
1401
  htmlFor: 'for',
1224
1402
  }
1225
1403
 
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
- */
1404
+ // DOM properties whose live value diverges from the content attribute.
1405
+ // For these, emit property assignment (`el.value = v`) instead of
1406
+ // `setAttribute("value", v)`. Otherwise the property and attribute drift
1407
+ // apart in user-driven flows: typing in a controlled <input> updates the
1408
+ // .value property, but `input.set('')` clearing the signal only resets
1409
+ // the attribute — the stale typed text stays visible. Same for `checked`
1410
+ // on checkboxes (presence of the attribute means checked regardless of
1411
+ // value: `setAttribute("checked", "false")` still checks the box).
1412
+ const DOM_PROPS = new Set([
1413
+ 'value',
1414
+ 'checked',
1415
+ 'selected',
1416
+ 'disabled',
1417
+ 'multiple',
1418
+ 'readOnly',
1419
+ 'indeterminate',
1420
+ ])
1421
+
1231
1422
  const STATEFUL_CALLS = new Set([
1232
1423
  'signal', 'computed', 'effect', 'batch',
1233
1424
  'createContext', 'createReactiveContext',
@@ -1236,24 +1427,23 @@ const STATEFUL_CALLS = new Set([
1236
1427
  'defineStore', 'useStore',
1237
1428
  ])
1238
1429
 
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)
1430
+ function isStatefulCall(node: N): boolean {
1431
+ if (node.type !== 'CallExpression') return false
1432
+ const callee = node.callee
1433
+ if (callee?.type === 'Identifier') return STATEFUL_CALLS.has(callee.name)
1243
1434
  return false
1244
1435
  }
1245
1436
 
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
1437
+ /** Check if a call expression creates a callable reactive value (`signal(...)` or `computed(...)`). */
1438
+ function isSignalCall(node: N): boolean {
1439
+ if (node.type !== 'CallExpression') return false
1440
+ const callee = node.callee
1441
+ return callee?.type === 'Identifier' && (callee.name === 'signal' || callee.name === 'computed')
1442
+ }
1443
+
1444
+ function isChildrenExpression(node: N, expr: string): boolean {
1445
+ if (node.type === 'MemberExpression' && !node.computed && node.property?.type === 'Identifier' && node.property.name === 'children') return true
1446
+ if (node.type === 'Identifier' && node.name === 'children') return true
1257
1447
  if (expr.endsWith('.children') || expr === 'children') return true
1258
1448
  return false
1259
1449
  }
@@ -1262,11 +1452,14 @@ function isLowerCase(s: string): boolean {
1262
1452
  return s.length > 0 && s[0] === s[0]?.toLowerCase()
1263
1453
  }
1264
1454
 
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
1455
+ function containsJSXInExpr(node: N): boolean {
1456
+ if (node.type === 'JSXElement' || node.type === 'JSXFragment') return true
1457
+ let found = false
1458
+ forEachChild(node, (child) => {
1459
+ if (found) return
1460
+ if (containsJSXInExpr(child)) found = true
1461
+ })
1462
+ return found
1270
1463
  }
1271
1464
 
1272
1465
  function escapeHtmlAttr(s: string): string {
@@ -1274,71 +1467,84 @@ function escapeHtmlAttr(s: string): string {
1274
1467
  }
1275
1468
 
1276
1469
  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
1470
  return s.replace(/&(?!(?:#\d+|#x[\da-fA-F]+|[a-zA-Z]\w*);)/g, '&amp;').replace(/</g, '&lt;')
1282
1471
  }
1283
1472
 
1284
- // ─── Static JSX analysis ──────────────────────────────────────────────────────
1285
-
1286
- type StaticJSXNode = ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment
1473
+ // React/Babel JSX whitespace algorithm (cleanJSXElementLiteralChild).
1474
+ // Same-line text is preserved verbatim so adjacent expressions keep their
1475
+ // spacing (`<p>doubled: {x}</p>` keeps the trailing space). Multi-line text
1476
+ // strips leading whitespace from non-first lines and trailing whitespace
1477
+ // from non-last lines, drops fully-empty lines, and joins the survivors
1478
+ // with a single space — collapsing JSX indentation without losing
1479
+ // intentional inline spacing.
1480
+ function cleanJsxText(raw: string): string {
1481
+ if (!raw.includes('\n') && !raw.includes('\r')) return raw
1482
+ const lines = raw.split(/\r\n|\n|\r/)
1483
+ let lastNonEmpty = -1
1484
+ for (let i = 0; i < lines.length; i++) {
1485
+ if (/[^ \t]/.test(lines[i] ?? '')) lastNonEmpty = i
1486
+ }
1487
+ let str = ''
1488
+ for (let i = 0; i < lines.length; i++) {
1489
+ let line = (lines[i] ?? '').replace(/\t/g, ' ')
1490
+ if (i !== 0) line = line.replace(/^ +/, '')
1491
+ if (i !== lines.length - 1) line = line.replace(/ +$/, '')
1492
+ if (line) {
1493
+ if (i !== lastNonEmpty) line += ' '
1494
+ str += line
1495
+ }
1496
+ }
1497
+ return str
1498
+ }
1287
1499
 
1288
- function isStaticJSXNode(node: StaticJSXNode): boolean {
1289
- if (ts.isJsxSelfClosingElement(node)) {
1290
- return isStaticAttrs(node.attributes)
1500
+ function isStaticJSXNode(node: N): boolean {
1501
+ if (node.type === 'JSXElement' && node.openingElement?.selfClosing) {
1502
+ return isStaticAttrs(node.openingElement.attributes ?? [])
1291
1503
  }
1292
- if (ts.isJsxFragment(node)) {
1293
- return node.children.every(isStaticChild)
1504
+ if (node.type === 'JSXFragment') {
1505
+ return (node.children ?? []).every(isStaticChild)
1294
1506
  }
1295
- // JsxElement
1296
- return isStaticAttrs(node.openingElement.attributes) && node.children.every(isStaticChild)
1507
+ if (node.type === 'JSXElement') {
1508
+ return isStaticAttrs(node.openingElement?.attributes ?? []) && (node.children ?? []).every(isStaticChild)
1509
+ }
1510
+ return false
1297
1511
  }
1298
1512
 
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
1513
+ function isStaticAttrs(attrs: N[]): boolean {
1514
+ return attrs.every((prop: N) => {
1515
+ if (prop.type !== 'JSXAttribute') return false
1516
+ if (!prop.value) return true
1517
+ if (prop.value.type === 'StringLiteral' || (prop.value.type === 'Literal' && typeof prop.value.value === 'string')) return true
1518
+ if (prop.value.type === 'JSXExpressionContainer') {
1519
+ const expr = prop.value.expression
1520
+ if (!expr || expr.type === 'JSXEmptyExpression') return true
1521
+ return isStatic(expr)
1522
+ }
1523
+ return false
1310
1524
  })
1311
1525
  }
1312
1526
 
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
1527
+ function isStaticChild(child: N): boolean {
1528
+ if (child.type === 'JSXText') return true
1529
+ if (child.type === 'JSXElement') return isStaticJSXNode(child)
1530
+ if (child.type === 'JSXFragment') return isStaticJSXNode(child)
1531
+ if (child.type === 'JSXExpressionContainer') {
1532
+ const expr = child.expression
1533
+ if (!expr || expr.type === 'JSXEmptyExpression') return true
1534
+ return isStatic(expr)
1535
+ }
1536
+ return false
1323
1537
  }
1324
1538
 
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).
1539
+ function isStatic(node: N): boolean {
1540
+ if (node.type === 'Literal') return true
1541
+ if (node.type === 'StringLiteral' || node.type === 'NumericLiteral' || node.type === 'BooleanLiteral' || node.type === 'NullLiteral') return true
1542
+ if (node.type === 'TemplateLiteral' && (node.expressions?.length ?? 0) === 0) return true
1543
+ // Note: `undefined` is an Identifier in ESTree, not a keyword literal.
1544
+ // It is NOT treated as static — it goes through the dynamic attr path.
1545
+ return false
1339
1546
  }
1340
1547
 
1341
- /** Known pure global functions that don't read signals. */
1342
1548
  const PURE_CALLS = new Set([
1343
1549
  'Math.max', 'Math.min', 'Math.abs', 'Math.floor', 'Math.ceil', 'Math.round',
1344
1550
  'Math.pow', 'Math.sqrt', 'Math.random', 'Math.trunc', 'Math.sign',
@@ -1353,30 +1559,14 @@ const PURE_CALLS = new Set([
1353
1559
  'Date.now',
1354
1560
  ])
1355
1561
 
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
1562
+ function isPureStaticCall(node: N): boolean {
1563
+ const callee = node.callee
1359
1564
  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}`
1565
+ if (callee?.type === 'Identifier') {
1566
+ name = callee.name
1567
+ } else if (callee?.type === 'MemberExpression' && !callee.computed && callee.object?.type === 'Identifier' && callee.property?.type === 'Identifier') {
1568
+ name = `${callee.object.name}.${callee.property.name}`
1365
1569
  }
1366
-
1367
1570
  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
- }
1571
+ return (node.arguments ?? []).every((arg: N) => arg.type !== 'SpreadElement' && isStatic(arg))
1572
+ }