what-compiler 0.8.4 → 0.10.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.
@@ -25,7 +25,7 @@ const VOID_HTML_ELEMENTS = new Set([
25
25
  ]);
26
26
 
27
27
  // Events that use document-level delegation for performance.
28
- // The compiler emits `el.__click = handler` instead of addEventListener.
28
+ // The compiler emits `el.$$click = handler` instead of addEventListener.
29
29
  // A one-time document listener walks event.target upward to find the handler.
30
30
  const DELEGATED_EVENTS = new Set([
31
31
  'click', 'input', 'change', 'keydown', 'keyup', 'submit',
@@ -47,13 +47,90 @@ const SIGNAL_CREATORS = new Set([
47
47
  'createResource', 'useSWR', 'useQuery', 'useInfiniteQuery',
48
48
  ]);
49
49
 
50
+ // Normalize JSX text per React/Babel rules:
51
+ // - Split on newlines, treat tabs as spaces.
52
+ // - For interior lines: trim leading and trailing horizontal whitespace.
53
+ // - For the first line: only trim trailing whitespace.
54
+ // - For the last line: only trim leading whitespace.
55
+ // - Skip lines that are entirely whitespace (don't add a separator space).
56
+ // - Join the remaining non-empty lines with single spaces.
57
+ //
58
+ // This preserves leading/trailing single-line whitespace that sits next to
59
+ // an expression like `{count} items` — without this, the space is eaten and
60
+ // the rendered output reads `5items`.
61
+ function normalizeJsxText(value) {
62
+ // Single-line text (no newlines): preserve the original (just tabs->spaces).
63
+ // This keeps the space in cases like `{a} {b}` where the JSXText is " ".
64
+ if (!/[\r\n]/.test(value)) {
65
+ return value.replace(/\t/g, ' ');
66
+ }
67
+ const lines = value.split(/\r\n|\n|\r/);
68
+ let lastNonEmpty = -1;
69
+ for (let i = 0; i < lines.length; i++) {
70
+ if (/[^ \t]/.test(lines[i])) lastNonEmpty = i;
71
+ }
72
+ if (lastNonEmpty === -1) return '';
73
+ let out = '';
74
+ for (let i = 0; i < lines.length; i++) {
75
+ let line = lines[i].replace(/\t/g, ' ');
76
+ const isFirst = i === 0;
77
+ const isLast = i === lines.length - 1;
78
+ if (!isFirst) line = line.replace(/^ +/, '');
79
+ if (!isLast) line = line.replace(/ +$/, '');
80
+ if (!line) continue;
81
+ if (i !== lastNonEmpty) line += ' ';
82
+ out += line;
83
+ }
84
+ return out;
85
+ }
86
+
50
87
  export default function whatBabelPlugin({ types: t }) {
51
88
  // =====================================================
52
89
  // Shared utilities
53
90
  // =====================================================
54
91
 
92
+ // Warn-once tracking for unknown event modifier segments. Keyed by
93
+ // `${filename}::${segment}` so each typo is reported at most once per file
94
+ // per compile process. Without the filename in the key, the same typo in
95
+ // two different files would silently warn for the first file only —
96
+ // problematic in long-running Vite dev servers.
97
+ const _unknownModifierWarned = new Set();
98
+ const _forInfoWarned = new Set();
99
+
100
+ function hasEventModifiers(name, state) {
101
+ // Any `__` in an `on*` attribute is intended as modifier syntax — even
102
+ // if every segment is unknown. Returning false there would emit the
103
+ // attribute as a plain delegated-event property (e.g.
104
+ // `el.$$onclick__totalyWrong = handler`), which never fires. Instead,
105
+ // always route through the modifier-handling branch so the parser can
106
+ // warn about the typo and drop the unknown segments.
107
+ if (!name.includes('__')) return false;
108
+ if (!name.startsWith('on')) return false;
109
+ const parts = name.split('__');
110
+ const tail = parts.slice(1).filter(s => s !== '');
111
+ if (tail.length === 0) return false;
112
+ if (process.env.NODE_ENV !== 'production') {
113
+ const unknown = tail.filter(m => !EVENT_MODIFIERS.has(m));
114
+ const filename = (state && (state.filename || (state.file && state.file.opts && state.file.opts.filename))) || '<unknown>';
115
+ for (const m of unknown) {
116
+ const key = `${filename}::${m}`;
117
+ if (!_unknownModifierWarned.has(key)) {
118
+ _unknownModifierWarned.add(key);
119
+ console.warn(
120
+ `[what-compiler] Unknown event modifier "__${m}" in attribute "${name}" (${filename}). ` +
121
+ `Known modifiers: ${[...EVENT_MODIFIERS].join(', ')}. ` +
122
+ `Unknown segments are ignored.`
123
+ );
124
+ }
125
+ }
126
+ }
127
+ return true;
128
+ }
129
+
55
130
  function parseEventModifiers(name) {
56
- const parts = name.split('|');
131
+ // Support both '|' (template strings) and '__' (JSX-safe) as modifier delimiters
132
+ const delimiter = name.includes('|') ? '|' : '__';
133
+ const parts = name.split(delimiter);
57
134
  const eventName = parts[0];
58
135
  const modifiers = parts.slice(1).filter(m => EVENT_MODIFIERS.has(m));
59
136
  return { eventName, modifiers };
@@ -211,27 +288,27 @@ export default function whatBabelPlugin({ types: t }) {
211
288
  }
212
289
  }
213
290
 
214
- // Walk up the scope chain using Babel's scope API
291
+ // Walk up the scope chain using Babel's scope API.
215
292
  let scope = path.scope;
216
293
  while (scope) {
217
- // Check all bindings in this scope
218
- for (const [name, binding] of Object.entries(scope.bindings)) {
294
+ // Check all variable bindings in this scope.
295
+ for (const binding of Object.values(scope.bindings)) {
219
296
  if (binding.path.isVariableDeclarator()) {
220
297
  extractFromDeclarator(binding.path.node);
221
298
  }
222
- // Also check function params (destructured props)
223
- if (binding.path.isIdentifier() || binding.kind === 'param') {
224
- const fnPath = binding.scope.path;
225
- if (fnPath && fnPath.node && fnPath.node.params) {
226
- for (const param of fnPath.node.params) {
227
- if (t.isObjectPattern(param)) {
228
- for (const prop of param.properties) {
229
- if (t.isObjectProperty(prop) && t.isIdentifier(prop.value)) {
230
- signalNames.add(prop.value.name);
231
- } else if (t.isRestElement(prop) && t.isIdentifier(prop.argument)) {
232
- signalNames.add(prop.argument.name);
233
- }
234
- }
299
+ }
300
+ // Scan this scope's own function params (destructured props) ONCE per
301
+ // scope not once per binding. The old per-binding rescan made this
302
+ // O(params × bindings) per scope per JSXElement. (AUDIT-2026-06-06 H2)
303
+ const fnNode = scope.path && scope.path.node;
304
+ if (fnNode && fnNode.params) {
305
+ for (const param of fnNode.params) {
306
+ if (t.isObjectPattern(param)) {
307
+ for (const prop of param.properties) {
308
+ if (t.isObjectProperty(prop) && t.isIdentifier(prop.value)) {
309
+ signalNames.add(prop.value.name);
310
+ } else if (t.isRestElement(prop) && t.isIdentifier(prop.argument)) {
311
+ signalNames.add(prop.argument.name);
235
312
  }
236
313
  }
237
314
  }
@@ -339,7 +416,8 @@ export default function whatBabelPlugin({ types: t }) {
339
416
  }
340
417
 
341
418
  if (t.isIdentifier(expr)) {
342
- return isSignalIdentifier(expr.name, signalNames);
419
+ return isSignalIdentifier(expr.name, signalNames) ||
420
+ (importedIds && importedIds.has(expr.name));
343
421
  }
344
422
 
345
423
  if (t.isMemberExpression(expr)) {
@@ -383,6 +461,137 @@ export default function whatBabelPlugin({ types: t }) {
383
461
  return false;
384
462
  }
385
463
 
464
+ // --- Auto-lower .map() to mapArray ---
465
+ // Detects: source().map((item) => <Comp key={expr} .../>)
466
+ // or wrapped in an arrow: () => source().map(...)
467
+ // Also walks into ternary (cond ? a.map(...) : fallback) and
468
+ // logical (cond && a.map(...)) expressions so React-style
469
+ // conditional list patterns get keyed reconciliation.
470
+ // Produces: _$mapArray(source, (item) => <Comp .../>, { key: item => expr })
471
+ function tryLowerMapToMapArray(expr, state) {
472
+ // Unwrap arrow function: () => source().map(...)
473
+ let mapCall = expr;
474
+ let wrappedInArrow = false;
475
+ if (t.isArrowFunctionExpression(expr) && expr.params.length === 0) {
476
+ mapCall = expr.body;
477
+ wrappedInArrow = true;
478
+ }
479
+
480
+ // Walk into ternary: cond ? arr().map(...) : fallback
481
+ if (t.isConditionalExpression(mapCall)) {
482
+ const loweredCon = tryLowerMapCall(mapCall.consequent, state);
483
+ const loweredAlt = tryLowerMapCall(mapCall.alternate, state);
484
+ if (loweredCon || loweredAlt) {
485
+ const result = t.conditionalExpression(
486
+ mapCall.test,
487
+ loweredCon || mapCall.consequent,
488
+ loweredAlt || mapCall.alternate
489
+ );
490
+ return wrappedInArrow ? t.arrowFunctionExpression([], result) : result;
491
+ }
492
+ return null;
493
+ }
494
+
495
+ // Walk into logical: cond && arr().map(...)
496
+ if (t.isLogicalExpression(mapCall) && (mapCall.operator === '&&' || mapCall.operator === '||')) {
497
+ const loweredRight = tryLowerMapCall(mapCall.right, state);
498
+ if (loweredRight) {
499
+ const result = t.logicalExpression(mapCall.operator, mapCall.left, loweredRight);
500
+ return wrappedInArrow ? t.arrowFunctionExpression([], result) : result;
501
+ }
502
+ return null;
503
+ }
504
+
505
+ // Direct .map() call
506
+ const lowered = tryLowerMapCall(mapCall, state);
507
+ return lowered;
508
+ }
509
+
510
+ // Core .map() lowering — extracted so it can be called per-branch
511
+ function tryLowerMapCall(mapCall, state) {
512
+ // Check: something.map(fn)
513
+ if (!t.isCallExpression(mapCall)) return null;
514
+ if (!t.isMemberExpression(mapCall.callee)) return null;
515
+ if (!t.isIdentifier(mapCall.callee.property, { name: 'map' })) return null;
516
+ if (mapCall.arguments.length < 1) return null;
517
+
518
+ const mapFn = mapCall.arguments[0];
519
+ if (!t.isArrowFunctionExpression(mapFn) && !t.isFunctionExpression(mapFn)) return null;
520
+
521
+ // Get the map callback's return expression
522
+ let returnExpr = null;
523
+ if (t.isArrowFunctionExpression(mapFn)) {
524
+ if (t.isExpression(mapFn.body)) {
525
+ returnExpr = mapFn.body;
526
+ } else if (t.isBlockStatement(mapFn.body)) {
527
+ const ret = mapFn.body.body.find(s => t.isReturnStatement(s));
528
+ if (ret) returnExpr = ret.argument;
529
+ }
530
+ } else if (t.isFunctionExpression(mapFn)) {
531
+ const ret = mapFn.body.body.find(s => t.isReturnStatement(s));
532
+ if (ret) returnExpr = ret.argument;
533
+ }
534
+
535
+ if (!returnExpr) return null;
536
+
537
+ // Check if the return is JSX with a `key` prop
538
+ if (!t.isJSXElement(returnExpr)) return null;
539
+ const attrs = returnExpr.openingElement.attributes;
540
+ let keyAttr = null;
541
+ for (const attr of attrs) {
542
+ if (t.isJSXAttribute(attr) && getAttrName(attr) === 'key') {
543
+ keyAttr = attr;
544
+ break;
545
+ }
546
+ }
547
+ if (!keyAttr) {
548
+ // JSX returned without a key — bail out, but warn at compile time so
549
+ // users notice they're missing keyed reconciliation. Only warn in dev
550
+ // (production builds are noiseless).
551
+ if (process.env.NODE_ENV !== 'production') {
552
+ const loc = returnExpr.loc;
553
+ const fileName = state.filename || state.file?.opts?.filename || '<unknown>';
554
+ const lineInfo = loc ? `:${loc.start.line}:${loc.start.column}` : '';
555
+ console.warn(
556
+ `[what-compiler] .map() returning JSX without a \`key\` prop at ${fileName}${lineInfo}. ` +
557
+ `Without a key, the list cannot use keyed reconciliation — items are re-created on every update. ` +
558
+ `Add key={...} to enable efficient updates.`
559
+ );
560
+ }
561
+ return null;
562
+ }
563
+
564
+ // Extract the key expression
565
+ const keyValue = getAttributeValue(keyAttr.value);
566
+ if (!keyValue) return null;
567
+
568
+ // Remove the key prop from the JSX element (mapArray handles keying, not the DOM)
569
+ returnExpr.openingElement.attributes = attrs.filter(a => a !== keyAttr);
570
+
571
+ // Build the source: the object before .map() — wrap in an arrow for reactive access
572
+ const sourceObj = mapCall.callee.object;
573
+ const source = t.arrowFunctionExpression([], sourceObj);
574
+
575
+ // Build the key function: (item) => keyExpr.
576
+ // Clone both the parameter and the key expression — the parameter is shared
577
+ // with the user's map callback AST and keyValue may be referenced elsewhere
578
+ // in the tree. Cloning insulates this new arrow from later mutations.
579
+ const itemParam = mapFn.params[0] ? t.cloneNode(mapFn.params[0], true) : t.identifier('_item');
580
+ const keyFn = t.arrowFunctionExpression([itemParam], t.cloneNode(keyValue, true));
581
+
582
+ // Build: _$mapArray(source, mapFn, { key: keyFn, raw: true })
583
+ // raw: true means mapFn receives the raw item value (not a signal accessor),
584
+ // matching user-authored .map() semantics where `item.prop` accesses values directly.
585
+ return t.callExpression(t.identifier('_$mapArray'), [
586
+ source,
587
+ mapFn,
588
+ t.objectExpression([
589
+ t.objectProperty(t.identifier('key'), keyFn),
590
+ t.objectProperty(t.identifier('raw'), t.booleanLiteral(true))
591
+ ])
592
+ ]);
593
+ }
594
+
386
595
  // =====================================================
387
596
  // Fine-Grained Mode (template + insert + effect)
388
597
  // =====================================================
@@ -415,7 +624,7 @@ export default function whatBabelPlugin({ types: t }) {
415
624
  // Extract static HTML from JSX element for template()
416
625
  function extractStaticHTML(node) {
417
626
  if (t.isJSXText(node)) {
418
- const text = node.value.replace(/\n\s+/g, ' ').trim();
627
+ const text = normalizeJsxText(node.value);
419
628
  return text ? escapeHTML(text) : '';
420
629
  }
421
630
 
@@ -467,7 +676,7 @@ export default function whatBabelPlugin({ types: t }) {
467
676
 
468
677
  for (const child of node.children) {
469
678
  if (t.isJSXText(child)) {
470
- const text = child.value.replace(/\n\s+/g, ' ').trim();
679
+ const text = normalizeJsxText(child.value);
471
680
  if (text) html += escapeHTML(text);
472
681
  } else if (t.isJSXExpressionContainer(child)) {
473
682
  if (!t.isJSXEmptyExpression(child.expression)) {
@@ -500,11 +709,7 @@ export default function whatBabelPlugin({ types: t }) {
500
709
  const openingElement = node.openingElement;
501
710
  const tagName = openingElement.name.name;
502
711
 
503
- if (isComponent(tagName)) {
504
- return transformComponentFineGrained(path, state);
505
- }
506
-
507
- // Control flow components (lowercase but special)
712
+ // Control flow components — check before generic isComponent since they start uppercase
508
713
  if (tagName === 'For') {
509
714
  return transformForFineGrained(path, state);
510
715
  }
@@ -512,6 +717,10 @@ export default function whatBabelPlugin({ types: t }) {
512
717
  return transformShowFineGrained(path, state);
513
718
  }
514
719
 
720
+ if (isComponent(tagName)) {
721
+ return transformComponentFineGrained(path, state);
722
+ }
723
+
515
724
  const attributes = openingElement.attributes;
516
725
  const children = node.children;
517
726
 
@@ -603,7 +812,7 @@ export default function whatBabelPlugin({ types: t }) {
603
812
  const transformedChildren = [];
604
813
  for (const child of children) {
605
814
  if (t.isJSXText(child)) {
606
- const text = child.value.replace(/\n\s+/g, ' ').trim();
815
+ const text = normalizeJsxText(child.value);
607
816
  if (text) transformedChildren.push(t.stringLiteral(text));
608
817
  } else if (t.isJSXExpressionContainer(child)) {
609
818
  if (!t.isJSXEmptyExpression(child.expression)) {
@@ -669,12 +878,12 @@ export default function whatBabelPlugin({ types: t }) {
669
878
  }
670
879
 
671
880
  // Event handlers
672
- if (attrName.startsWith('on') && !attrName.includes('|')) {
881
+ if (attrName.startsWith('on') && !attrName.includes('|') && !hasEventModifiers(attrName, state)) {
673
882
  const event = attrName.slice(2).toLowerCase();
674
883
  const handler = getAttributeValue(attr.value);
675
884
 
676
885
  if (DELEGATED_EVENTS.has(event)) {
677
- // Use event delegation: el.__click = handler
886
+ // Use event delegation: el.$$click = handler (matches runtime lookup)
678
887
  state.needsDelegation = true;
679
888
  if (!state.delegatedEvents) state.delegatedEvents = new Set();
680
889
  state.delegatedEvents.add(event);
@@ -683,7 +892,7 @@ export default function whatBabelPlugin({ types: t }) {
683
892
  t.assignmentExpression('=',
684
893
  t.memberExpression(
685
894
  t.identifier(elId),
686
- t.identifier(`__${event}`)
895
+ t.identifier(`$$${event}`)
687
896
  ),
688
897
  handler
689
898
  )
@@ -703,8 +912,8 @@ export default function whatBabelPlugin({ types: t }) {
703
912
  continue;
704
913
  }
705
914
 
706
- // Event with modifiers
707
- if (attrName.startsWith('on') && attrName.includes('|')) {
915
+ // Event with modifiers (pipe '|' or JSX-safe double underscore '__')
916
+ if (attrName.startsWith('on') && (attrName.includes('|') || hasEventModifiers(attrName, state))) {
708
917
  const { eventName, modifiers } = parseEventModifiers(attrName);
709
918
  const handler = getAttributeValue(attr.value);
710
919
  const wrappedHandler = createEventHandler(handler, modifiers);
@@ -810,8 +1019,14 @@ export default function whatBabelPlugin({ types: t }) {
810
1019
 
811
1020
  if (isPotentiallyReactive(expr, state.signalNames, state.importedIdentifiers)) {
812
1021
  state.needsEffect = true;
1022
+ // Auto-invoke bare signal/imported identifiers: value={name} -> name()
1023
+ const valueExpr = t.isIdentifier(expr) &&
1024
+ (isSignalIdentifier(expr.name, state.signalNames) ||
1025
+ (state.importedIdentifiers && state.importedIdentifiers.has(expr.name)))
1026
+ ? t.callExpression(expr, [])
1027
+ : expr;
813
1028
  const effectCall = t.callExpression(t.identifier('_$effect'), [
814
- t.arrowFunctionExpression([], buildSetPropCall(domName, expr))
1029
+ t.arrowFunctionExpression([], buildSetPropCall(domName, valueExpr))
815
1030
  ]);
816
1031
  // In dev mode, add a leading comment when the effect wrapping is uncertain
817
1032
  // (non-signal function call whose args happen to contain signal reads)
@@ -841,7 +1056,7 @@ export default function whatBabelPlugin({ types: t }) {
841
1056
 
842
1057
  for (const child of children) {
843
1058
  if (t.isJSXText(child)) {
844
- const text = child.value.replace(/\n\s+/g, ' ').trim();
1059
+ const text = normalizeJsxText(child.value);
845
1060
  if (text) childIndex++;
846
1061
  continue;
847
1062
  }
@@ -881,24 +1096,43 @@ export default function whatBabelPlugin({ types: t }) {
881
1096
  e.type === 'expression' || e.type === 'component' ||
882
1097
  (e.type === 'static' && e.hasAnythingDynamic)
883
1098
  );
884
- const hasDynamicInsert = entries.some(e => e.type === 'expression' || e.type === 'component');
885
- const needsPreCapture = entriesNeedingRef.length >= 2 && hasDynamicInsert;
1099
+ // Pre-capture whenever 2+ children need a DOM ref. Beyond preventing index
1100
+ // shift after insert() mutations, the shared O(n) cursor walk below replaces
1101
+ // per-child `el.firstChild.nextSibling…`-from-root access, which was O(n²) in
1102
+ // both compile time and emitted size for elements with many dynamic
1103
+ // children. (AUDIT-2026-06-06 H2)
1104
+ const needsPreCapture = entriesNeedingRef.length >= 2;
886
1105
 
887
1106
  const markerVars = new Map(); // childIndex → variable name
888
1107
  if (needsPreCapture) {
1108
+ // Chain each marker from the PREVIOUS captured cursor instead of
1109
+ // re-walking `el.firstChild.nextSibling…` from the root for every child.
1110
+ // entriesNeedingRef is in ascending childIndex order, so the per-marker
1111
+ // deltas sum to O(n) total instead of O(n²). This was the dominant
1112
+ // quadratic in compile time and emitted-bundle size for large elements.
1113
+ // (AUDIT-2026-06-06 H2)
1114
+ let prevVar = null;
1115
+ let prevIndex = 0;
889
1116
  for (const entry of entriesNeedingRef) {
890
- const varName = `_m$${entry.childIndex}`;
891
- // Use a unique name to avoid collisions with element vars
1117
+ const idx = entry.childIndex;
892
1118
  const markerVar = state.nextVarId();
893
- markerVars.set(entry.childIndex, markerVar);
1119
+ markerVars.set(idx, markerVar);
1120
+ let init;
1121
+ if (prevVar === null) {
1122
+ init = buildChildAccess(elId, idx);
1123
+ } else {
1124
+ init = t.identifier(prevVar);
1125
+ for (let i = prevIndex; i < idx; i++) {
1126
+ init = t.memberExpression(init, t.identifier('nextSibling'));
1127
+ }
1128
+ }
894
1129
  statements.push(
895
1130
  t.variableDeclaration('const', [
896
- t.variableDeclarator(
897
- t.identifier(markerVar),
898
- buildChildAccess(elId, entry.childIndex)
899
- )
1131
+ t.variableDeclarator(t.identifier(markerVar), init)
900
1132
  ])
901
1133
  );
1134
+ prevVar = markerVar;
1135
+ prevIndex = idx;
902
1136
  }
903
1137
  }
904
1138
 
@@ -913,10 +1147,58 @@ export default function whatBabelPlugin({ types: t }) {
913
1147
  // --- Pass 2: Generate code using stable references ---
914
1148
  for (const entry of entries) {
915
1149
  if (entry.type === 'expression') {
916
- const expr = entry.child.expression;
1150
+ let expr = entry.child.expression;
917
1151
  const marker = getMarker(entry.childIndex);
918
1152
  state.needsInsert = true;
919
1153
 
1154
+ // Auto-lower .map() to mapArray when the callback returns keyed JSX.
1155
+ // Pattern: source().map(item => <Comp key={...} />) or source().map((item, i) => ...)
1156
+ const mapResult = tryLowerMapToMapArray(expr, state);
1157
+ if (mapResult) {
1158
+ state.needsMapArray = true;
1159
+ // A bare _$mapArray(...) call is a self-managing inserter (it tracks
1160
+ // its source internally) and an arrow is already reactive — pass both
1161
+ // raw. But when lowering produced a ternary/logical wrapping the call
1162
+ // (e.g. cond ? _$mapArray(...) : fallback), the surrounding condition
1163
+ // must stay reactive, so wrap the whole expression in () => and let
1164
+ // _$insert re-evaluate it on change. Without this the condition is read
1165
+ // exactly once and never re-tracks. (AUDIT-2026-06-06 H1)
1166
+ const isBareMapArray = t.isCallExpression(mapResult) && t.isIdentifier(mapResult.callee) &&
1167
+ (mapResult.callee.name === '_$mapArray' || mapResult.callee.name === 'mapArray');
1168
+ const isArrowAlready = t.isArrowFunctionExpression(mapResult);
1169
+ const insertArg = (isBareMapArray || isArrowAlready)
1170
+ ? mapResult
1171
+ : t.arrowFunctionExpression([], mapResult);
1172
+ statements.push(
1173
+ t.expressionStatement(
1174
+ t.callExpression(t.identifier('_$insert'), [
1175
+ t.identifier(elId),
1176
+ insertArg,
1177
+ marker
1178
+ ])
1179
+ )
1180
+ );
1181
+ continue;
1182
+ }
1183
+
1184
+ // mapArray() calls return self-managing inserters — pass directly, never wrap in () =>
1185
+ const isMapArrayCall = t.isCallExpression(expr) && t.isIdentifier(expr.callee) &&
1186
+ (expr.callee.name === 'mapArray' || expr.callee.name === '_$mapArray');
1187
+ if (isMapArrayCall) {
1188
+ state.needsMapArray = true;
1189
+ if (expr.callee.name === 'mapArray') expr.callee.name = '_$mapArray';
1190
+ statements.push(
1191
+ t.expressionStatement(
1192
+ t.callExpression(t.identifier('_$insert'), [
1193
+ t.identifier(elId),
1194
+ expr,
1195
+ marker
1196
+ ])
1197
+ )
1198
+ );
1199
+ continue;
1200
+ }
1201
+
920
1202
  if (isPotentiallyReactive(expr, state.signalNames, state.importedIdentifiers)) {
921
1203
  const insertCall = t.callExpression(t.identifier('_$insert'), [
922
1204
  t.identifier(elId),
@@ -1153,7 +1435,7 @@ export default function whatBabelPlugin({ types: t }) {
1153
1435
  }
1154
1436
 
1155
1437
  // Handle event modifiers on components
1156
- if (attrName.startsWith('on') && attrName.includes('|')) {
1438
+ if (attrName.startsWith('on') && (attrName.includes('|') || hasEventModifiers(attrName, state))) {
1157
1439
  const { eventName, modifiers } = parseEventModifiers(attrName);
1158
1440
  const handler = getAttributeValue(attr.value);
1159
1441
  const wrappedHandler = createEventHandler(handler, modifiers);
@@ -1177,7 +1459,7 @@ export default function whatBabelPlugin({ types: t }) {
1177
1459
  const transformedChildren = [];
1178
1460
  for (const child of children) {
1179
1461
  if (t.isJSXText(child)) {
1180
- const text = child.value.replace(/\n\s+/g, ' ').trim();
1462
+ const text = normalizeJsxText(child.value);
1181
1463
  if (text) transformedChildren.push(t.stringLiteral(text));
1182
1464
  } else if (t.isJSXExpressionContainer(child)) {
1183
1465
  if (!t.isJSXEmptyExpression(child.expression)) {
@@ -1218,12 +1500,35 @@ export default function whatBabelPlugin({ types: t }) {
1218
1500
  const attributes = node.openingElement.attributes;
1219
1501
  const children = node.children;
1220
1502
 
1221
- // <For each={data}>{(item) => <Row />}</For>
1222
- // → mapArray(data, (item) => ...)
1503
+ // <For each={data} key={item => item.id}>{(item) => <Row />}</For>
1504
+ // → mapArray(data, (item) => ..., { key: item => item.id })
1505
+ //
1506
+ // NOTE: <For> is supported but .map() with a key prop is the preferred
1507
+ // pattern for list rendering. The compiler auto-lowers .map() to
1508
+ // _$mapArray with raw mode, which is simpler and matches JS idioms.
1509
+ // <For> is useful when you need signal-wrapped item accessors (keyed
1510
+ // mode without raw), so that item updates don't recreate DOM nodes.
1511
+ if (process.env.NODE_ENV !== 'production') {
1512
+ const fileName = state.filename || state.file?.opts?.filename || '<unknown>';
1513
+ if (!_forInfoWarned.has(fileName)) {
1514
+ _forInfoWarned.add(fileName);
1515
+ const loc = node.loc;
1516
+ const lineInfo = loc ? `:${loc.start.line}:${loc.start.column}` : '';
1517
+ console.info(
1518
+ `[what-compiler] <For> at ${fileName}${lineInfo}: consider using .map() with a key prop instead. ` +
1519
+ `The compiler auto-lowers .map() to efficient keyed reconciliation. ` +
1520
+ `<For> is only needed for signal-wrapped item accessors (advanced).`
1521
+ );
1522
+ }
1523
+ }
1524
+
1223
1525
  let eachExpr = null;
1526
+ let keyExpr = null;
1224
1527
  for (const attr of attributes) {
1225
- if (t.isJSXAttribute(attr) && getAttrName(attr) === 'each') {
1226
- eachExpr = getAttributeValue(attr.value);
1528
+ if (t.isJSXAttribute(attr)) {
1529
+ const name = getAttrName(attr);
1530
+ if (name === 'each') eachExpr = getAttributeValue(attr.value);
1531
+ else if (name === 'key') keyExpr = getAttributeValue(attr.value);
1227
1532
  }
1228
1533
  }
1229
1534
 
@@ -1248,14 +1553,120 @@ export default function whatBabelPlugin({ types: t }) {
1248
1553
  }
1249
1554
 
1250
1555
  state.needsMapArray = true;
1251
- return t.callExpression(t.identifier('_$mapArray'), [eachExpr, renderFn]);
1556
+ const args = [eachExpr, renderFn];
1557
+ if (keyExpr) {
1558
+ args.push(t.objectExpression([
1559
+ t.objectProperty(t.identifier('key'), keyExpr)
1560
+ ]));
1561
+ }
1562
+ return t.callExpression(t.identifier('_$mapArray'), args);
1252
1563
  }
1253
1564
 
1254
1565
  function transformShowFineGrained(path, state) {
1255
- // <Show when={cond}>{content}</Show>
1256
- // Uses _$createComponent(Show, ...) Show is a runtime component
1257
- state.needsCreateComponent = true;
1258
- return transformComponentFineGrained(path, state);
1566
+ // <Show when={cond} fallback={alt}>{content}</Show>
1567
+ // () => cond() ? content : (fallback || null)
1568
+ // This compiles to a reactive expression that insert() wraps in an effect.
1569
+ const { node } = path;
1570
+ const attributes = node.openingElement.attributes;
1571
+ const children = node.children;
1572
+
1573
+ let whenExpr = null;
1574
+ let fallbackExpr = null;
1575
+ for (const attr of attributes) {
1576
+ if (t.isJSXAttribute(attr)) {
1577
+ const name = getAttrName(attr);
1578
+ if (name === 'when') whenExpr = getAttributeValue(attr.value);
1579
+ else if (name === 'fallback') fallbackExpr = getAttributeValue(attr.value);
1580
+ }
1581
+ }
1582
+
1583
+ if (!whenExpr) {
1584
+ // <Show> without a when prop has no defined semantics — fail loudly at
1585
+ // build time so the user fixes their source instead of seeing runtime
1586
+ // confusion. buildCodeFrameError pins the error to the JSX location.
1587
+ throw path.buildCodeFrameError(
1588
+ '<Show> requires a "when" prop. Example: <Show when={isOpen} fallback={null}>...</Show>'
1589
+ );
1590
+ }
1591
+
1592
+ // Extract the content — either a render function child or static JSX children
1593
+ let contentExpr = null;
1594
+ for (const child of children) {
1595
+ if (t.isJSXExpressionContainer(child) && !t.isJSXEmptyExpression(child.expression)) {
1596
+ // Render function: {() => <div>...</div>} or {(value) => <div>{value}</div>}
1597
+ contentExpr = child.expression;
1598
+ break;
1599
+ }
1600
+ }
1601
+
1602
+ if (!contentExpr) {
1603
+ // Static children — collect and transform them
1604
+ const transformedChildren = [];
1605
+ for (const child of children) {
1606
+ if (t.isJSXText(child)) {
1607
+ const text = normalizeJsxText(child.value);
1608
+ if (text) transformedChildren.push(t.stringLiteral(text));
1609
+ } else if (t.isJSXElement(child)) {
1610
+ transformedChildren.push(transformElementFineGrained({ node: child }, state));
1611
+ }
1612
+ }
1613
+ if (transformedChildren.length === 1) {
1614
+ contentExpr = transformedChildren[0];
1615
+ } else if (transformedChildren.length > 1) {
1616
+ contentExpr = t.arrayExpression(transformedChildren);
1617
+ } else {
1618
+ contentExpr = t.nullLiteral();
1619
+ }
1620
+ }
1621
+
1622
+ // Build:
1623
+ // () => { const _v = <condition>; return _v ? <consequent> : <alternate>; }
1624
+ // Hoisting into a local prevents double-evaluation of the `when` signal
1625
+ // (the consequent's render callback also needs the resolved value).
1626
+ //
1627
+ // `whenExpr` shape determines how we form the condition:
1628
+ // - call expression → use as-is <Show when={cond()}>
1629
+ // - arrow w/ expression body → use the body <Show when={() => x > 5}>
1630
+ // - identifier that looks like a signal/import <Show when={isOpen}>
1631
+ // → invoke it as accessor: isOpen()
1632
+ // - anything else (member, literal, logical, etc.) <Show when={user.isAdmin}>
1633
+ // → use the raw expression. Do NOT invoke —
1634
+ // non-functions would throw at runtime.
1635
+ let condition;
1636
+ if (t.isCallExpression(whenExpr)) {
1637
+ condition = whenExpr;
1638
+ } else if (t.isArrowFunctionExpression(whenExpr) && t.isExpression(whenExpr.body)) {
1639
+ condition = whenExpr.body;
1640
+ } else if (
1641
+ t.isIdentifier(whenExpr) &&
1642
+ (
1643
+ (state.signalNames && isSignalIdentifier(whenExpr.name, state.signalNames)) ||
1644
+ (state.importedIdentifiers && state.importedIdentifiers.has(whenExpr.name))
1645
+ )
1646
+ ) {
1647
+ condition = t.callExpression(whenExpr, []);
1648
+ } else {
1649
+ // Plain boolean expression — member access, literal, logical, etc.
1650
+ condition = whenExpr;
1651
+ }
1652
+
1653
+ const vId = path.scope
1654
+ ? path.scope.generateUidIdentifier('v')
1655
+ : t.identifier('_v');
1656
+
1657
+ const consequent = t.isFunction(contentExpr)
1658
+ ? t.callExpression(contentExpr, [t.cloneNode(vId)])
1659
+ : contentExpr;
1660
+ const alternate = fallbackExpr || t.nullLiteral();
1661
+
1662
+ return t.arrowFunctionExpression([], t.blockStatement([
1663
+ t.variableDeclaration('const', [
1664
+ t.variableDeclarator(vId, condition)
1665
+ ]),
1666
+ t.returnStatement(
1667
+ t.conditionalExpression(t.cloneNode(vId), consequent, alternate)
1668
+ )
1669
+ ]));
1259
1670
  }
1260
1671
 
1261
1672
  function transformFragmentFineGrained(path, state) {
@@ -1265,7 +1676,7 @@ export default function whatBabelPlugin({ types: t }) {
1265
1676
  const transformed = [];
1266
1677
  for (const child of children) {
1267
1678
  if (t.isJSXText(child)) {
1268
- const text = child.value.replace(/\n\s+/g, ' ').trim();
1679
+ const text = normalizeJsxText(child.value);
1269
1680
  if (text) transformed.push(t.stringLiteral(text));
1270
1681
  } else if (t.isJSXExpressionContainer(child)) {
1271
1682
  if (!t.isJSXEmptyExpression(child.expression)) {
@@ -1520,27 +1931,65 @@ export default function whatBabelPlugin({ types: t }) {
1520
1931
  },
1521
1932
 
1522
1933
  JSXElement(path, state) {
1523
- // FIX-1: Use scope-aware signal detection instead of file-global
1524
- state.signalNames = collectSignalNamesFromScope(path);
1934
+ // FIX-1: Use scope-aware signal detection instead of file-global.
1935
+ // Memoize per Babel scope: every JSXElement in the same scope yields the
1936
+ // same signal-name set, so without this the full scope-chain walk ran
1937
+ // once per element — O(n²) compile time for a large single component.
1938
+ // (AUDIT-2026-06-06 H2)
1939
+ const scope = path.scope;
1940
+ let cache = state._signalNamesCache;
1941
+ if (!cache) cache = state._signalNamesCache = new WeakMap();
1942
+ let names = cache.get(scope);
1943
+ if (!names) {
1944
+ names = collectSignalNamesFromScope(path);
1945
+ cache.set(scope, names);
1946
+ }
1947
+ state.signalNames = names;
1525
1948
  state._pendingSetup = [];
1526
1949
  const transformed = transformElementFineGrained(path, state);
1527
1950
  const pending = state._pendingSetup;
1528
1951
  state._pendingSetup = [];
1529
1952
 
1530
1953
  if (pending.length > 0) {
1531
- // Find the enclosing statement to hoist setup before it
1954
+ // Find the enclosing statement to hoist setup before it,
1955
+ // but only if it's in the SAME function scope. Crossing into
1956
+ // an inner arrow/function (e.g., .map(item => <JSX/>)) would
1957
+ // hoist references to closure variables out of scope.
1532
1958
  let stmtPath = path;
1959
+ let crossedFunctionBoundary = false;
1533
1960
  while (stmtPath && !stmtPath.isStatement()) {
1961
+ if (stmtPath.isArrowFunctionExpression() || stmtPath.isFunctionExpression()) {
1962
+ crossedFunctionBoundary = true;
1963
+ }
1534
1964
  stmtPath = stmtPath.parentPath;
1535
1965
  }
1536
- if (stmtPath && stmtPath.isStatement()) {
1537
- // Insert setup statements before the enclosing statement
1538
- for (const stmt of pending) {
1539
- stmtPath.insertBefore(stmt);
1540
- }
1966
+ // We can safely hoist setup as siblings of `stmtPath` ONLY if
1967
+ // `stmtPath` lives inside a statement list (BlockStatement.body or
1968
+ // Program.body). For single-statement positions like
1969
+ // `if (cond) return <jsx/>;` or `while (x) return <jsx/>;`,
1970
+ // Babel's `insertBefore` wraps the parent into a block lazily and
1971
+ // multi-statement inserts end up split across scopes, leaving the
1972
+ // `_$insert(_el$N, ...)` call outside the block that declares
1973
+ // `const _el$N`. This is a TDZ/ReferenceError at runtime.
1974
+ //
1975
+ // To guarantee that ALL setup statements and the returned reference
1976
+ // share one lexical block, require that `stmtPath.listKey` points
1977
+ // at a statement list. Otherwise fall through to the IIFE path,
1978
+ // which is always safe.
1979
+ const inStatementList =
1980
+ stmtPath
1981
+ && stmtPath.isStatement()
1982
+ && (stmtPath.listKey === 'body' || stmtPath.listKey === 'consequent')
1983
+ && Array.isArray(stmtPath.container);
1984
+ if (inStatementList && !crossedFunctionBoundary) {
1985
+ // Same function scope — safe to hoist setup before the enclosing
1986
+ // statement. Works for return statements too: `insertBefore`
1987
+ // places setup above `return <jsx/>` without wrapping in an IIFE.
1988
+ stmtPath.insertBefore(pending);
1541
1989
  path.replaceWith(transformed);
1542
1990
  } else {
1543
- // Fallback: if we can't find a statement parent, use IIFE
1991
+ // Crossed a function boundary or no enclosing statement found
1992
+ // fall back to IIFE so closure variables remain in scope.
1544
1993
  pending.push(t.returnStatement(transformed));
1545
1994
  path.replaceWith(
1546
1995
  t.callExpression(