what-compiler 0.8.4 → 0.11.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
 
@@ -564,7 +773,7 @@ export default function whatBabelPlugin({ types: t }) {
564
773
  ];
565
774
 
566
775
  // Apply dynamic attributes and events
567
- applyDynamicAttrs(statements, elId, attributes, state);
776
+ applyDynamicAttrs(statements, elId, attributes, state, tagName);
568
777
 
569
778
  // Handle dynamic children
570
779
  applyDynamicChildren(statements, elId, children, node, state);
@@ -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)) {
@@ -620,8 +829,51 @@ export default function whatBabelPlugin({ types: t }) {
620
829
  return t.callExpression(t.identifier('h'), [t.stringLiteral(tagName), propsExpr, ...transformedChildren]);
621
830
  }
622
831
 
623
- function applyDynamicAttrs(statements, elId, attributes, state) {
832
+ // Tags where `value` / `checked` are live DOM properties the user expects a
833
+ // dynamic binding to drive (input.value, select.value, input.checked, ...).
834
+ // Other tags keep the generic setProp path (e.g. <div value={x}> sets an
835
+ // attribute, <li value={n}> hits the `key in el` property branch).
836
+ const VALUE_PROP_TAGS = new Set(['input', 'textarea', 'select', 'option']);
837
+
838
+ function applyDynamicAttrs(statements, elId, attributes, state, tagName) {
839
+ // Specialized monomorphic setters for statically-known attribute names
840
+ // (SPRINT v0.11 C2). The generic _$setProp re-dispatches on the key string
841
+ // (ref/key/url/class/style/innerHTML/boolean/...) on EVERY reactive update;
842
+ // when the compiler knows the name it emits the direct helper instead.
843
+ // SECURITY: URL attributes (href/src/action/formaction) and innerHTML/
844
+ // dangerouslySetInnerHTML intentionally fall through to _$setProp — URL
845
+ // sanitization and the { __html } enforcement live there.
624
846
  function buildSetPropCall(propName, valueExpr) {
847
+ if (propName === 'class') {
848
+ // normalizeAttrName already mapped className → class
849
+ state.needsSetClass = true;
850
+ return t.callExpression(t.identifier('_$setClass'), [t.identifier(elId), valueExpr]);
851
+ }
852
+ if (propName === 'style') {
853
+ state.needsSetStyle = true;
854
+ return t.callExpression(t.identifier('_$setStyle'), [t.identifier(elId), valueExpr]);
855
+ }
856
+ if (propName === 'value' && tagName && VALUE_PROP_TAGS.has(tagName)) {
857
+ state.needsSetValue = true;
858
+ return t.callExpression(t.identifier('_$setValue'), [t.identifier(elId), valueExpr]);
859
+ }
860
+ if (propName === 'checked' && tagName === 'input') {
861
+ // Live property write — matches bind:checked semantics. (The old
862
+ // setAttribute('checked') path only set the DEFAULT-checked state,
863
+ // which stops reflecting once the user has toggled the input.)
864
+ // A helper (not a raw `.checked =`) so function values still get
865
+ // reactive-accessor treatment, like every other setter.
866
+ state.needsSetChecked = true;
867
+ return t.callExpression(t.identifier('_$setChecked'), [t.identifier(elId), valueExpr]);
868
+ }
869
+ if (propName.startsWith('data-') || propName.startsWith('aria-')) {
870
+ state.needsSetAttr = true;
871
+ return t.callExpression(t.identifier('_$setAttr'), [
872
+ t.identifier(elId),
873
+ t.stringLiteral(propName),
874
+ valueExpr
875
+ ]);
876
+ }
625
877
  state.needsSetProp = true;
626
878
  return t.callExpression(t.identifier('_$setProp'), [
627
879
  t.identifier(elId),
@@ -630,6 +882,18 @@ export default function whatBabelPlugin({ types: t }) {
630
882
  ]);
631
883
  }
632
884
 
885
+ // Lazy delegation init (C6): the first element of a module that assigns a
886
+ // delegated `$$event` handler also calls the once-guarded _$delegate$()
887
+ // helper at construction time. Emitted at most once per element.
888
+ let delegateInitEmitted = false;
889
+ function emitDelegateInit() {
890
+ if (delegateInitEmitted) return;
891
+ delegateInitEmitted = true;
892
+ statements.push(
893
+ t.expressionStatement(t.callExpression(t.identifier('_$delegate$'), []))
894
+ );
895
+ }
896
+
633
897
  for (const attr of attributes) {
634
898
  if (t.isJSXSpreadAttribute(attr)) {
635
899
  state.needsSpread = true;
@@ -669,21 +933,22 @@ export default function whatBabelPlugin({ types: t }) {
669
933
  }
670
934
 
671
935
  // Event handlers
672
- if (attrName.startsWith('on') && !attrName.includes('|')) {
936
+ if (attrName.startsWith('on') && !attrName.includes('|') && !hasEventModifiers(attrName, state)) {
673
937
  const event = attrName.slice(2).toLowerCase();
674
938
  const handler = getAttributeValue(attr.value);
675
939
 
676
940
  if (DELEGATED_EVENTS.has(event)) {
677
- // Use event delegation: el.__click = handler
941
+ // Use event delegation: el.$$click = handler (matches runtime lookup)
678
942
  state.needsDelegation = true;
679
943
  if (!state.delegatedEvents) state.delegatedEvents = new Set();
680
944
  state.delegatedEvents.add(event);
945
+ emitDelegateInit();
681
946
  statements.push(
682
947
  t.expressionStatement(
683
948
  t.assignmentExpression('=',
684
949
  t.memberExpression(
685
950
  t.identifier(elId),
686
- t.identifier(`__${event}`)
951
+ t.identifier(`$$${event}`)
687
952
  ),
688
953
  handler
689
954
  )
@@ -703,8 +968,8 @@ export default function whatBabelPlugin({ types: t }) {
703
968
  continue;
704
969
  }
705
970
 
706
- // Event with modifiers
707
- if (attrName.startsWith('on') && attrName.includes('|')) {
971
+ // Event with modifiers (pipe '|' or JSX-safe double underscore '__')
972
+ if (attrName.startsWith('on') && (attrName.includes('|') || hasEventModifiers(attrName, state))) {
708
973
  const { eventName, modifiers } = parseEventModifiers(attrName);
709
974
  const handler = getAttributeValue(attr.value);
710
975
  const wrappedHandler = createEventHandler(handler, modifiers);
@@ -810,8 +1075,14 @@ export default function whatBabelPlugin({ types: t }) {
810
1075
 
811
1076
  if (isPotentiallyReactive(expr, state.signalNames, state.importedIdentifiers)) {
812
1077
  state.needsEffect = true;
1078
+ // Auto-invoke bare signal/imported identifiers: value={name} -> name()
1079
+ const valueExpr = t.isIdentifier(expr) &&
1080
+ (isSignalIdentifier(expr.name, state.signalNames) ||
1081
+ (state.importedIdentifiers && state.importedIdentifiers.has(expr.name)))
1082
+ ? t.callExpression(expr, [])
1083
+ : expr;
813
1084
  const effectCall = t.callExpression(t.identifier('_$effect'), [
814
- t.arrowFunctionExpression([], buildSetPropCall(domName, expr))
1085
+ t.arrowFunctionExpression([], buildSetPropCall(domName, valueExpr))
815
1086
  ]);
816
1087
  // In dev mode, add a leading comment when the effect wrapping is uncertain
817
1088
  // (non-signal function call whose args happen to contain signal reads)
@@ -830,6 +1101,99 @@ export default function whatBabelPlugin({ types: t }) {
830
1101
  }
831
1102
  }
832
1103
 
1104
+ // =====================================================
1105
+ // Branch Memoization (SPRINT v0.11 C1)
1106
+ // =====================================================
1107
+ // `_$insert(el, () => cond() ? <A/> : <B/>)` re-creates the taken branch's
1108
+ // DOM tree (and re-registers every effect inside it) on EVERY re-evaluation
1109
+ // of the insert effect — including writes to signals read by the condition
1110
+ // that do NOT flip which branch is taken (e.g. `count() > 5` while count
1111
+ // goes 6 → 7). Solid solves this by memoizing the condition: route the
1112
+ // condition through an eager, equality-gated memo so the insert effect
1113
+ // depends on the *memo* instead of the raw signals:
1114
+ //
1115
+ // const _c$0 = _$memo(() => !!(count() > 5));
1116
+ // _$insert(_el$, () => _c$0() ? <A/> : <B/>, marker);
1117
+ //
1118
+ // The memo re-evaluates on every count write, but only NOTIFIES when its
1119
+ // value changes — so branch DOM is recreated exactly on real flips.
1120
+ //
1121
+ // Semantics preserved:
1122
+ // - Ternary tests only matter for truthiness → memoize `!!test`.
1123
+ // - `a && b` / `a || b` render the LEFT operand's VALUE when it
1124
+ // short-circuits (`{0 && <div/>}` renders "0"), so the left side is
1125
+ // memoized by value (Object.is) — never coerced.
1126
+ // - Branch-internal reactivity (signals read inside <A/>) is fine-grained
1127
+ // and unaffected; plain-value branches read in the insert arrow are
1128
+ // still tracked by the insert effect directly.
1129
+
1130
+ // Does this expression produce DOM when evaluated? (raw JSX still present at
1131
+ // this stage, or an already-lowered _$mapArray list). Only then is branch
1132
+ // memoization worth the extra memo node.
1133
+ function buildsDOM(node) {
1134
+ if (!node || typeof node !== 'object') return false;
1135
+ if (Array.isArray(node)) return node.some(buildsDOM);
1136
+ if (node.type === 'JSXElement' || node.type === 'JSXFragment') return true;
1137
+ if (node.type === 'CallExpression' && node.callee &&
1138
+ node.callee.type === 'Identifier' &&
1139
+ (node.callee.name === '_$mapArray' || node.callee.name === 'mapArray')) {
1140
+ return true;
1141
+ }
1142
+ for (const key of Object.keys(node)) {
1143
+ if (key === 'loc' || key === 'start' || key === 'end' || key === 'leadingComments' ||
1144
+ key === 'trailingComments' || key === 'innerComments') continue;
1145
+ const v = node[key];
1146
+ if (v && typeof v === 'object' && buildsDOM(v)) return true;
1147
+ }
1148
+ return false;
1149
+ }
1150
+
1151
+ // If `expr` is a conditional (ternary / && / ||) with a reactive test and a
1152
+ // DOM-producing branch, hoist the test into `const _c$N = _$memo(...)` (pushed
1153
+ // onto `statements`) and return the expression rewritten to read the memo.
1154
+ // Otherwise returns `expr` unchanged.
1155
+ function memoizeBranchCondition(expr, statements, state) {
1156
+ let testExpr = null;
1157
+ let isTernary = false;
1158
+ if (t.isConditionalExpression(expr)) {
1159
+ testExpr = expr.test;
1160
+ isTernary = true;
1161
+ } else if (t.isLogicalExpression(expr) && (expr.operator === '&&' || expr.operator === '||')) {
1162
+ testExpr = expr.left;
1163
+ } else {
1164
+ return expr;
1165
+ }
1166
+
1167
+ if (!isPotentiallyReactive(testExpr, state.signalNames, state.importedIdentifiers)) return expr;
1168
+
1169
+ const branches = isTernary ? [expr.consequent, expr.alternate] : [expr.right];
1170
+ if (!branches.some(buildsDOM)) return expr;
1171
+
1172
+ const condId = state.nextMemoId();
1173
+ state.needsMemo = true;
1174
+ // Ternary: only truthiness matters in test position → gate on !!test so
1175
+ // value changes that don't flip truthiness (5 → 6) never notify.
1176
+ // Logical: the left VALUE is rendered on short-circuit → gate on the value.
1177
+ const memoBody = isTernary
1178
+ ? t.unaryExpression('!', t.unaryExpression('!', testExpr))
1179
+ : testExpr;
1180
+ statements.push(
1181
+ t.variableDeclaration('const', [
1182
+ t.variableDeclarator(
1183
+ t.identifier(condId),
1184
+ t.callExpression(t.identifier('_$memo'), [
1185
+ t.arrowFunctionExpression([], memoBody)
1186
+ ])
1187
+ )
1188
+ ])
1189
+ );
1190
+
1191
+ const condRead = t.callExpression(t.identifier(condId), []);
1192
+ return isTernary
1193
+ ? t.conditionalExpression(condRead, expr.consequent, expr.alternate)
1194
+ : t.logicalExpression(expr.operator, condRead, expr.right);
1195
+ }
1196
+
833
1197
  function applyDynamicChildren(statements, elId, children, parentNode, state) {
834
1198
  // Two-pass approach: first collect all children needing DOM references,
835
1199
  // then pre-capture markers before any _$insert() calls shift indices.
@@ -841,7 +1205,7 @@ export default function whatBabelPlugin({ types: t }) {
841
1205
 
842
1206
  for (const child of children) {
843
1207
  if (t.isJSXText(child)) {
844
- const text = child.value.replace(/\n\s+/g, ' ').trim();
1208
+ const text = normalizeJsxText(child.value);
845
1209
  if (text) childIndex++;
846
1210
  continue;
847
1211
  }
@@ -881,24 +1245,43 @@ export default function whatBabelPlugin({ types: t }) {
881
1245
  e.type === 'expression' || e.type === 'component' ||
882
1246
  (e.type === 'static' && e.hasAnythingDynamic)
883
1247
  );
884
- const hasDynamicInsert = entries.some(e => e.type === 'expression' || e.type === 'component');
885
- const needsPreCapture = entriesNeedingRef.length >= 2 && hasDynamicInsert;
1248
+ // Pre-capture whenever 2+ children need a DOM ref. Beyond preventing index
1249
+ // shift after insert() mutations, the shared O(n) cursor walk below replaces
1250
+ // per-child `el.firstChild.nextSibling…`-from-root access, which was O(n²) in
1251
+ // both compile time and emitted size for elements with many dynamic
1252
+ // children. (AUDIT-2026-06-06 H2)
1253
+ const needsPreCapture = entriesNeedingRef.length >= 2;
886
1254
 
887
1255
  const markerVars = new Map(); // childIndex → variable name
888
1256
  if (needsPreCapture) {
1257
+ // Chain each marker from the PREVIOUS captured cursor instead of
1258
+ // re-walking `el.firstChild.nextSibling…` from the root for every child.
1259
+ // entriesNeedingRef is in ascending childIndex order, so the per-marker
1260
+ // deltas sum to O(n) total instead of O(n²). This was the dominant
1261
+ // quadratic in compile time and emitted-bundle size for large elements.
1262
+ // (AUDIT-2026-06-06 H2)
1263
+ let prevVar = null;
1264
+ let prevIndex = 0;
889
1265
  for (const entry of entriesNeedingRef) {
890
- const varName = `_m$${entry.childIndex}`;
891
- // Use a unique name to avoid collisions with element vars
1266
+ const idx = entry.childIndex;
892
1267
  const markerVar = state.nextVarId();
893
- markerVars.set(entry.childIndex, markerVar);
1268
+ markerVars.set(idx, markerVar);
1269
+ let init;
1270
+ if (prevVar === null) {
1271
+ init = buildChildAccess(elId, idx);
1272
+ } else {
1273
+ init = t.identifier(prevVar);
1274
+ for (let i = prevIndex; i < idx; i++) {
1275
+ init = t.memberExpression(init, t.identifier('nextSibling'));
1276
+ }
1277
+ }
894
1278
  statements.push(
895
1279
  t.variableDeclaration('const', [
896
- t.variableDeclarator(
897
- t.identifier(markerVar),
898
- buildChildAccess(elId, entry.childIndex)
899
- )
1280
+ t.variableDeclarator(t.identifier(markerVar), init)
900
1281
  ])
901
1282
  );
1283
+ prevVar = markerVar;
1284
+ prevIndex = idx;
902
1285
  }
903
1286
  }
904
1287
 
@@ -913,11 +1296,71 @@ export default function whatBabelPlugin({ types: t }) {
913
1296
  // --- Pass 2: Generate code using stable references ---
914
1297
  for (const entry of entries) {
915
1298
  if (entry.type === 'expression') {
916
- const expr = entry.child.expression;
1299
+ let expr = entry.child.expression;
917
1300
  const marker = getMarker(entry.childIndex);
918
1301
  state.needsInsert = true;
919
1302
 
1303
+ // Auto-lower .map() to mapArray when the callback returns keyed JSX.
1304
+ // Pattern: source().map(item => <Comp key={...} />) or source().map((item, i) => ...)
1305
+ let mapResult = tryLowerMapToMapArray(expr, state);
1306
+ if (mapResult) {
1307
+ state.needsMapArray = true;
1308
+ // A bare _$mapArray(...) call is a self-managing inserter (it tracks
1309
+ // its source internally) and an arrow is already reactive — pass both
1310
+ // raw. But when lowering produced a ternary/logical wrapping the call
1311
+ // (e.g. cond ? _$mapArray(...) : fallback), the surrounding condition
1312
+ // must stay reactive, so wrap the whole expression in () => and let
1313
+ // _$insert re-evaluate it on change. Without this the condition is read
1314
+ // exactly once and never re-tracks. (AUDIT-2026-06-06 H1)
1315
+ const isBareMapArray = t.isCallExpression(mapResult) && t.isIdentifier(mapResult.callee) &&
1316
+ (mapResult.callee.name === '_$mapArray' || mapResult.callee.name === 'mapArray');
1317
+ const isArrowAlready = t.isArrowFunctionExpression(mapResult);
1318
+ // Branch memoization (C1): when the lowered result is a conditional
1319
+ // around the list (cond ? _$mapArray(...) : fallback), memoize the
1320
+ // condition so non-flip writes don't tear down and recreate the
1321
+ // entire list inserter.
1322
+ if (isArrowAlready && t.isExpression(mapResult.body)) {
1323
+ mapResult.body = memoizeBranchCondition(mapResult.body, statements, state);
1324
+ } else if (!isBareMapArray && !isArrowAlready) {
1325
+ mapResult = memoizeBranchCondition(mapResult, statements, state);
1326
+ }
1327
+ const insertArg = (isBareMapArray || isArrowAlready)
1328
+ ? mapResult
1329
+ : t.arrowFunctionExpression([], mapResult);
1330
+ statements.push(
1331
+ t.expressionStatement(
1332
+ t.callExpression(t.identifier('_$insert'), [
1333
+ t.identifier(elId),
1334
+ insertArg,
1335
+ marker
1336
+ ])
1337
+ )
1338
+ );
1339
+ continue;
1340
+ }
1341
+
1342
+ // mapArray() calls return self-managing inserters — pass directly, never wrap in () =>
1343
+ const isMapArrayCall = t.isCallExpression(expr) && t.isIdentifier(expr.callee) &&
1344
+ (expr.callee.name === 'mapArray' || expr.callee.name === '_$mapArray');
1345
+ if (isMapArrayCall) {
1346
+ state.needsMapArray = true;
1347
+ if (expr.callee.name === 'mapArray') expr.callee.name = '_$mapArray';
1348
+ statements.push(
1349
+ t.expressionStatement(
1350
+ t.callExpression(t.identifier('_$insert'), [
1351
+ t.identifier(elId),
1352
+ expr,
1353
+ marker
1354
+ ])
1355
+ )
1356
+ );
1357
+ continue;
1358
+ }
1359
+
920
1360
  if (isPotentiallyReactive(expr, state.signalNames, state.importedIdentifiers)) {
1361
+ // Branch memoization (C1): conditional children only rebuild branch
1362
+ // DOM when the condition actually flips.
1363
+ expr = memoizeBranchCondition(expr, statements, state);
921
1364
  const insertCall = t.callExpression(t.identifier('_$insert'), [
922
1365
  t.identifier(elId),
923
1366
  t.arrowFunctionExpression([], expr),
@@ -976,7 +1419,7 @@ export default function whatBabelPlugin({ types: t }) {
976
1419
  ])
977
1420
  );
978
1421
  }
979
- applyDynamicAttrs(statements, childElRef, entry.child.openingElement.attributes, state);
1422
+ applyDynamicAttrs(statements, childElRef, entry.child.openingElement.attributes, state, entry.child.openingElement.name.name);
980
1423
  applyDynamicChildren(statements, childElRef, entry.child.children, entry.child, state);
981
1424
  continue;
982
1425
  }
@@ -985,8 +1428,9 @@ export default function whatBabelPlugin({ types: t }) {
985
1428
  for (const fChild of entry.child.children) {
986
1429
  if (t.isJSXExpressionContainer(fChild) && !t.isJSXEmptyExpression(fChild.expression)) {
987
1430
  state.needsInsert = true;
988
- const expr = fChild.expression;
1431
+ let expr = fChild.expression;
989
1432
  if (isPotentiallyReactive(expr, state.signalNames, state.importedIdentifiers)) {
1433
+ expr = memoizeBranchCondition(expr, statements, state); // (C1)
990
1434
  statements.push(
991
1435
  t.expressionStatement(
992
1436
  t.callExpression(t.identifier('_$insert'), [
@@ -1153,7 +1597,7 @@ export default function whatBabelPlugin({ types: t }) {
1153
1597
  }
1154
1598
 
1155
1599
  // Handle event modifiers on components
1156
- if (attrName.startsWith('on') && attrName.includes('|')) {
1600
+ if (attrName.startsWith('on') && (attrName.includes('|') || hasEventModifiers(attrName, state))) {
1157
1601
  const { eventName, modifiers } = parseEventModifiers(attrName);
1158
1602
  const handler = getAttributeValue(attr.value);
1159
1603
  const wrappedHandler = createEventHandler(handler, modifiers);
@@ -1177,7 +1621,7 @@ export default function whatBabelPlugin({ types: t }) {
1177
1621
  const transformedChildren = [];
1178
1622
  for (const child of children) {
1179
1623
  if (t.isJSXText(child)) {
1180
- const text = child.value.replace(/\n\s+/g, ' ').trim();
1624
+ const text = normalizeJsxText(child.value);
1181
1625
  if (text) transformedChildren.push(t.stringLiteral(text));
1182
1626
  } else if (t.isJSXExpressionContainer(child)) {
1183
1627
  if (!t.isJSXEmptyExpression(child.expression)) {
@@ -1218,12 +1662,35 @@ export default function whatBabelPlugin({ types: t }) {
1218
1662
  const attributes = node.openingElement.attributes;
1219
1663
  const children = node.children;
1220
1664
 
1221
- // <For each={data}>{(item) => <Row />}</For>
1222
- // → mapArray(data, (item) => ...)
1665
+ // <For each={data} key={item => item.id}>{(item) => <Row />}</For>
1666
+ // → mapArray(data, (item) => ..., { key: item => item.id })
1667
+ //
1668
+ // NOTE: <For> is supported but .map() with a key prop is the preferred
1669
+ // pattern for list rendering. The compiler auto-lowers .map() to
1670
+ // _$mapArray with raw mode, which is simpler and matches JS idioms.
1671
+ // <For> is useful when you need signal-wrapped item accessors (keyed
1672
+ // mode without raw), so that item updates don't recreate DOM nodes.
1673
+ if (process.env.NODE_ENV !== 'production') {
1674
+ const fileName = state.filename || state.file?.opts?.filename || '<unknown>';
1675
+ if (!_forInfoWarned.has(fileName)) {
1676
+ _forInfoWarned.add(fileName);
1677
+ const loc = node.loc;
1678
+ const lineInfo = loc ? `:${loc.start.line}:${loc.start.column}` : '';
1679
+ console.info(
1680
+ `[what-compiler] <For> at ${fileName}${lineInfo}: consider using .map() with a key prop instead. ` +
1681
+ `The compiler auto-lowers .map() to efficient keyed reconciliation. ` +
1682
+ `<For> is only needed for signal-wrapped item accessors (advanced).`
1683
+ );
1684
+ }
1685
+ }
1686
+
1223
1687
  let eachExpr = null;
1688
+ let keyExpr = null;
1224
1689
  for (const attr of attributes) {
1225
- if (t.isJSXAttribute(attr) && getAttrName(attr) === 'each') {
1226
- eachExpr = getAttributeValue(attr.value);
1690
+ if (t.isJSXAttribute(attr)) {
1691
+ const name = getAttrName(attr);
1692
+ if (name === 'each') eachExpr = getAttributeValue(attr.value);
1693
+ else if (name === 'key') keyExpr = getAttributeValue(attr.value);
1227
1694
  }
1228
1695
  }
1229
1696
 
@@ -1248,14 +1715,209 @@ export default function whatBabelPlugin({ types: t }) {
1248
1715
  }
1249
1716
 
1250
1717
  state.needsMapArray = true;
1251
- return t.callExpression(t.identifier('_$mapArray'), [eachExpr, renderFn]);
1718
+ const args = [eachExpr, renderFn];
1719
+ if (keyExpr) {
1720
+ args.push(t.objectExpression([
1721
+ t.objectProperty(t.identifier('key'), keyExpr)
1722
+ ]));
1723
+ }
1724
+ return t.callExpression(t.identifier('_$mapArray'), args);
1252
1725
  }
1253
1726
 
1254
1727
  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);
1728
+ // <Show when={cond} fallback={alt}>{content}</Show>
1729
+ // () => cond() ? content : (fallback || null)
1730
+ // This compiles to a reactive expression that insert() wraps in an effect.
1731
+ const { node } = path;
1732
+ const attributes = node.openingElement.attributes;
1733
+ const children = node.children;
1734
+
1735
+ let whenExpr = null;
1736
+ let fallbackExpr = null;
1737
+ for (const attr of attributes) {
1738
+ if (t.isJSXAttribute(attr)) {
1739
+ const name = getAttrName(attr);
1740
+ if (name === 'when') whenExpr = getAttributeValue(attr.value);
1741
+ else if (name === 'fallback') fallbackExpr = getAttributeValue(attr.value);
1742
+ }
1743
+ }
1744
+
1745
+ if (!whenExpr) {
1746
+ // <Show> without a when prop has no defined semantics — fail loudly at
1747
+ // build time so the user fixes their source instead of seeing runtime
1748
+ // confusion. buildCodeFrameError pins the error to the JSX location.
1749
+ throw path.buildCodeFrameError(
1750
+ '<Show> requires a "when" prop. Example: <Show when={isOpen} fallback={null}>...</Show>'
1751
+ );
1752
+ }
1753
+
1754
+ // Extract the content — either a render function child or static JSX children
1755
+ let contentExpr = null;
1756
+ for (const child of children) {
1757
+ if (t.isJSXExpressionContainer(child) && !t.isJSXEmptyExpression(child.expression)) {
1758
+ // Render function: {() => <div>...</div>} or {(value) => <div>{value}</div>}
1759
+ contentExpr = child.expression;
1760
+ break;
1761
+ }
1762
+ }
1763
+
1764
+ if (!contentExpr) {
1765
+ // Static children — collect and transform them
1766
+ const transformedChildren = [];
1767
+ for (const child of children) {
1768
+ if (t.isJSXText(child)) {
1769
+ const text = normalizeJsxText(child.value);
1770
+ if (text) transformedChildren.push(t.stringLiteral(text));
1771
+ } else if (t.isJSXElement(child)) {
1772
+ transformedChildren.push(transformElementFineGrained({ node: child }, state));
1773
+ }
1774
+ }
1775
+ if (transformedChildren.length === 1) {
1776
+ contentExpr = transformedChildren[0];
1777
+ } else if (transformedChildren.length > 1) {
1778
+ contentExpr = t.arrayExpression(transformedChildren);
1779
+ } else {
1780
+ contentExpr = t.nullLiteral();
1781
+ }
1782
+ }
1783
+
1784
+ // Build:
1785
+ // () => { const _v = <condition>; return _v ? <consequent> : <alternate>; }
1786
+ // Hoisting into a local prevents double-evaluation of the `when` signal
1787
+ // (the consequent's render callback also needs the resolved value).
1788
+ //
1789
+ // `whenExpr` shape determines how we form the condition:
1790
+ // - call expression → use as-is <Show when={cond()}>
1791
+ // - arrow w/ expression body → use the body <Show when={() => x > 5}>
1792
+ // - identifier that looks like a signal/import <Show when={isOpen}>
1793
+ // → invoke it as accessor: isOpen()
1794
+ // - anything else (member, literal, logical, etc.) <Show when={user.isAdmin}>
1795
+ // → use the raw expression. Do NOT invoke —
1796
+ // non-functions would throw at runtime.
1797
+ let condition;
1798
+ if (t.isCallExpression(whenExpr)) {
1799
+ condition = whenExpr;
1800
+ } else if (t.isArrowFunctionExpression(whenExpr) && t.isExpression(whenExpr.body)) {
1801
+ condition = whenExpr.body;
1802
+ } else if (
1803
+ t.isIdentifier(whenExpr) &&
1804
+ (
1805
+ (state.signalNames && isSignalIdentifier(whenExpr.name, state.signalNames)) ||
1806
+ (state.importedIdentifiers && state.importedIdentifiers.has(whenExpr.name))
1807
+ )
1808
+ ) {
1809
+ condition = t.callExpression(whenExpr, []);
1810
+ } else {
1811
+ // Plain boolean expression — member access, literal, logical, etc.
1812
+ condition = whenExpr;
1813
+ }
1814
+
1815
+ const vId = path.scope
1816
+ ? path.scope.generateUidIdentifier('v')
1817
+ : t.identifier('_v');
1818
+
1819
+ const contentIsFn = t.isFunction(contentExpr);
1820
+ const consequent = contentIsFn
1821
+ ? t.callExpression(contentExpr, [t.cloneNode(vId)])
1822
+ : contentExpr;
1823
+ const alternate = fallbackExpr || t.nullLiteral();
1824
+
1825
+ // Branch memoization (SPRINT v0.11 C1): route a reactive `when` through an
1826
+ // equality-gated memo so the insert effect only re-fires (recreating the
1827
+ // taken branch's DOM) when the condition actually changes — not on every
1828
+ // write to a signal the condition happens to read.
1829
+ // - Render-function children receive the resolved value (`{v => ...}`),
1830
+ // so the memo is VALUE-gated (Object.is): identity changes re-render,
1831
+ // matching pre-memo semantics.
1832
+ // - Static children only use the value for truthiness → gate on !!cond
1833
+ // so e.g. `when={items().length}` doesn't re-render on 2 → 3.
1834
+ if (isPotentiallyReactive(condition, state.signalNames, state.importedIdentifiers)) {
1835
+ const condId = state.nextMemoId();
1836
+ state.needsMemo = true;
1837
+ const memoBody = contentIsFn
1838
+ ? condition
1839
+ : t.unaryExpression('!', t.unaryExpression('!', condition));
1840
+ if (!state._pendingSetup) state._pendingSetup = [];
1841
+ state._pendingSetup.push(
1842
+ t.variableDeclaration('const', [
1843
+ t.variableDeclarator(
1844
+ t.identifier(condId),
1845
+ t.callExpression(t.identifier('_$memo'), [
1846
+ t.arrowFunctionExpression([], memoBody)
1847
+ ])
1848
+ )
1849
+ ])
1850
+ );
1851
+ condition = t.callExpression(t.identifier(condId), []);
1852
+ }
1853
+
1854
+ return t.arrowFunctionExpression([], t.blockStatement([
1855
+ t.variableDeclaration('const', [
1856
+ t.variableDeclarator(vId, condition)
1857
+ ]),
1858
+ t.returnStatement(
1859
+ t.conditionalExpression(t.cloneNode(vId), consequent, alternate)
1860
+ )
1861
+ ]));
1862
+ }
1863
+
1864
+ // A fragment-as-root returns an array (or single value) that the runtime
1865
+ // mounts element-by-element (createDOM / insert). Unlike the element-child
1866
+ // path there's no host element to _$insert into with a marker, so reactive
1867
+ // children must instead be emitted as `() => expr` arrows — the runtime's
1868
+ // createDOM/insert treat a function array-item as a reactive binding (it
1869
+ // wraps it in an effect with comment markers). Without this, a bare dynamic
1870
+ // expression like `{count()}` is evaluated exactly once and never updates.
1871
+ //
1872
+ // This applies the SAME lowering the element-child expression path uses:
1873
+ // - tryLowerMapToMapArray for keyed `items().map(...)` → _$mapArray
1874
+ // - memoizeBranchCondition for reactive ternary/&&/|| (node-identity stable
1875
+ // branches: the taken branch's DOM is only rebuilt when the condition
1876
+ // actually flips, not on every read of a signal the condition touches)
1877
+ // - reactive expressions wrapped in `() =>` so the runtime tracks them
1878
+ // Memo (_$memo) declarations are pushed into state._pendingSetup, which
1879
+ // transformJsxRoot drains into the enclosing scope. (SPRINT v0.11)
1880
+ function lowerFragmentExprChild(expr, state) {
1881
+ if (!state._pendingSetup) state._pendingSetup = [];
1882
+ const setup = state._pendingSetup;
1883
+
1884
+ // Auto-lower .map() to mapArray when the callback returns keyed JSX.
1885
+ const mapResult = tryLowerMapToMapArray(expr, state);
1886
+ if (mapResult) {
1887
+ state.needsMapArray = true;
1888
+ // A bare _$mapArray(...) is a self-managing inserter and an arrow is
1889
+ // already reactive — emit as-is. A ternary/logical wrapping the call
1890
+ // keeps its condition reactive via a () => wrapper (and memoization).
1891
+ const isBareMapArray = t.isCallExpression(mapResult) && t.isIdentifier(mapResult.callee) &&
1892
+ (mapResult.callee.name === '_$mapArray' || mapResult.callee.name === 'mapArray');
1893
+ const isArrowAlready = t.isArrowFunctionExpression(mapResult);
1894
+ if (isArrowAlready && t.isExpression(mapResult.body)) {
1895
+ mapResult.body = memoizeBranchCondition(mapResult.body, setup, state);
1896
+ return mapResult;
1897
+ }
1898
+ if (isBareMapArray) return mapResult;
1899
+ const memoized = memoizeBranchCondition(mapResult, setup, state);
1900
+ return t.arrowFunctionExpression([], memoized);
1901
+ }
1902
+
1903
+ // mapArray() calls are self-managing inserters — pass directly.
1904
+ const isMapArrayCall = t.isCallExpression(expr) && t.isIdentifier(expr.callee) &&
1905
+ (expr.callee.name === 'mapArray' || expr.callee.name === '_$mapArray');
1906
+ if (isMapArrayCall) {
1907
+ state.needsMapArray = true;
1908
+ if (expr.callee.name === 'mapArray') expr.callee.name = '_$mapArray';
1909
+ return expr;
1910
+ }
1911
+
1912
+ if (isPotentiallyReactive(expr, state.signalNames, state.importedIdentifiers)) {
1913
+ // Branch memoization (C1): conditional/logical children only rebuild the
1914
+ // taken branch's DOM when the condition actually flips.
1915
+ expr = memoizeBranchCondition(expr, setup, state);
1916
+ return t.arrowFunctionExpression([], expr);
1917
+ }
1918
+
1919
+ // Static — emit verbatim.
1920
+ return expr;
1259
1921
  }
1260
1922
 
1261
1923
  function transformFragmentFineGrained(path, state) {
@@ -1265,11 +1927,11 @@ export default function whatBabelPlugin({ types: t }) {
1265
1927
  const transformed = [];
1266
1928
  for (const child of children) {
1267
1929
  if (t.isJSXText(child)) {
1268
- const text = child.value.replace(/\n\s+/g, ' ').trim();
1930
+ const text = normalizeJsxText(child.value);
1269
1931
  if (text) transformed.push(t.stringLiteral(text));
1270
1932
  } else if (t.isJSXExpressionContainer(child)) {
1271
1933
  if (!t.isJSXEmptyExpression(child.expression)) {
1272
- transformed.push(child.expression);
1934
+ transformed.push(lowerFragmentExprChild(child.expression, state));
1273
1935
  }
1274
1936
  } else if (t.isJSXElement(child)) {
1275
1937
  transformed.push(transformElementFineGrained({ node: child }, state));
@@ -1293,6 +1955,93 @@ export default function whatBabelPlugin({ types: t }) {
1293
1955
  return id;
1294
1956
  }
1295
1957
 
1958
+ // Shared driver for top-level JSX roots (elements AND fragments).
1959
+ //
1960
+ // transformElementFineGrained does NOT emit IIFEs: it pushes setup
1961
+ // statements (`const _el$N = _tmpl$X(); _el$N.$$click = ...; _$insert(...)`)
1962
+ // into state._pendingSetup and returns the bare `_el$N` identifier. Whoever
1963
+ // visits the JSX root is responsible for draining _pendingSetup and placing
1964
+ // those statements somewhere the returned reference can see them.
1965
+ //
1966
+ // The JSXElement visitor always did this; the JSXFragment visitor did not,
1967
+ // so fragments whose element children had dynamic parts (event handlers,
1968
+ // dynamic attrs/children) compiled to references to _el$N variables that
1969
+ // were never declared — a runtime ReferenceError. Both visitors now share
1970
+ // this driver. (SPRINT v0.11: composes with C1 branch memoization and C2
1971
+ // specialized setters — memo/setter statements ride in _pendingSetup too.)
1972
+ function transformJsxRoot(path, state, transform) {
1973
+ // FIX-1: Use scope-aware signal detection instead of file-global.
1974
+ // Memoize per Babel scope: every JSX root in the same scope yields the
1975
+ // same signal-name set, so without this the full scope-chain walk ran
1976
+ // once per element — O(n²) compile time for a large single component.
1977
+ // (AUDIT-2026-06-06 H2)
1978
+ const scope = path.scope;
1979
+ let cache = state._signalNamesCache;
1980
+ if (!cache) cache = state._signalNamesCache = new WeakMap();
1981
+ let names = cache.get(scope);
1982
+ if (!names) {
1983
+ names = collectSignalNamesFromScope(path);
1984
+ cache.set(scope, names);
1985
+ }
1986
+ state.signalNames = names;
1987
+ state._pendingSetup = [];
1988
+ const transformed = transform(path, state);
1989
+ const pending = state._pendingSetup;
1990
+ state._pendingSetup = [];
1991
+
1992
+ if (pending.length > 0) {
1993
+ // Find the enclosing statement to hoist setup before it,
1994
+ // but only if it's in the SAME function scope. Crossing into
1995
+ // an inner arrow/function (e.g., .map(item => <JSX/>)) would
1996
+ // hoist references to closure variables out of scope.
1997
+ let stmtPath = path;
1998
+ let crossedFunctionBoundary = false;
1999
+ while (stmtPath && !stmtPath.isStatement()) {
2000
+ if (stmtPath.isArrowFunctionExpression() || stmtPath.isFunctionExpression()) {
2001
+ crossedFunctionBoundary = true;
2002
+ }
2003
+ stmtPath = stmtPath.parentPath;
2004
+ }
2005
+ // We can safely hoist setup as siblings of `stmtPath` ONLY if
2006
+ // `stmtPath` lives inside a statement list (BlockStatement.body or
2007
+ // Program.body). For single-statement positions like
2008
+ // `if (cond) return <jsx/>;` or `while (x) return <jsx/>;`,
2009
+ // Babel's `insertBefore` wraps the parent into a block lazily and
2010
+ // multi-statement inserts end up split across scopes, leaving the
2011
+ // `_$insert(_el$N, ...)` call outside the block that declares
2012
+ // `const _el$N`. This is a TDZ/ReferenceError at runtime.
2013
+ //
2014
+ // To guarantee that ALL setup statements and the returned reference
2015
+ // share one lexical block, require that `stmtPath.listKey` points
2016
+ // at a statement list. Otherwise fall through to the IIFE path,
2017
+ // which is always safe.
2018
+ const inStatementList =
2019
+ stmtPath
2020
+ && stmtPath.isStatement()
2021
+ && (stmtPath.listKey === 'body' || stmtPath.listKey === 'consequent')
2022
+ && Array.isArray(stmtPath.container);
2023
+ if (inStatementList && !crossedFunctionBoundary) {
2024
+ // Same function scope — safe to hoist setup before the enclosing
2025
+ // statement. Works for return statements too: `insertBefore`
2026
+ // places setup above `return <jsx/>` without wrapping in an IIFE.
2027
+ stmtPath.insertBefore(pending);
2028
+ path.replaceWith(transformed);
2029
+ } else {
2030
+ // Crossed a function boundary or no enclosing statement found —
2031
+ // fall back to IIFE so closure variables remain in scope.
2032
+ pending.push(t.returnStatement(transformed));
2033
+ path.replaceWith(
2034
+ t.callExpression(
2035
+ t.arrowFunctionExpression([], t.blockStatement(pending)),
2036
+ []
2037
+ )
2038
+ );
2039
+ }
2040
+ } else {
2041
+ path.replaceWith(transformed);
2042
+ }
2043
+ }
2044
+
1296
2045
  // =====================================================
1297
2046
  // Plugin entry
1298
2047
  // =====================================================
@@ -1310,6 +2059,12 @@ export default function whatBabelPlugin({ types: t }) {
1310
2059
  state.needsMapArray = false;
1311
2060
  state.needsSpread = false;
1312
2061
  state.needsSetProp = false;
2062
+ state.needsMemo = false; // branch memoization (C1)
2063
+ state.needsSetClass = false; // specialized setters (C2)
2064
+ state.needsSetStyle = false;
2065
+ state.needsSetAttr = false;
2066
+ state.needsSetValue = false;
2067
+ state.needsSetChecked = false;
1313
2068
  state.needsH = false;
1314
2069
  state.needsCreateComponent = false;
1315
2070
  state.needsFragment = false;
@@ -1320,8 +2075,10 @@ export default function whatBabelPlugin({ types: t }) {
1320
2075
  state.templateMap = new Map(); // html → template id (deduplication)
1321
2076
  state.templateCount = 0;
1322
2077
  state._varCounter = 0;
2078
+ state._memoCounter = 0;
1323
2079
  state._pendingSetup = [];
1324
2080
  state.nextVarId = () => `_el$${state._varCounter++}`;
2081
+ state.nextMemoId = () => `_c$${state._memoCounter++}`;
1325
2082
 
1326
2083
  // Collect signal names for smart reactivity detection
1327
2084
  state.signalNames = new Set();
@@ -1400,12 +2157,14 @@ export default function whatBabelPlugin({ types: t }) {
1400
2157
  exit(path, state) {
1401
2158
  // Insert template declarations at top of program (hoisted to module scope)
1402
2159
  for (const tmpl of state.templates.reverse()) {
2160
+ // /* @__PURE__ */ marks the hoisted call as side-effect-free so
2161
+ // bundlers (esbuild/rollup/terser) can drop templates whose
2162
+ // components are tree-shaken away. (SPRINT v0.11 C6)
2163
+ const tmplCall = t.callExpression(t.identifier('_$template'), [t.stringLiteral(tmpl.html)]);
2164
+ t.addComment(tmplCall, 'leading', ' @__PURE__ ');
1403
2165
  path.unshiftContainer('body',
1404
2166
  t.variableDeclaration('const', [
1405
- t.variableDeclarator(
1406
- t.identifier(tmpl.id),
1407
- t.callExpression(t.identifier('_$template'), [t.stringLiteral(tmpl.html)])
1408
- )
2167
+ t.variableDeclarator(t.identifier(tmpl.id), tmplCall)
1409
2168
  ])
1410
2169
  );
1411
2170
  }
@@ -1413,8 +2172,13 @@ export default function whatBabelPlugin({ types: t }) {
1413
2172
  // Build fine-grained imports
1414
2173
  const fgSpecifiers = [];
1415
2174
  if (state.needsTemplate) {
2175
+ // Import the compiler-internal `_$template` export, NOT the public
2176
+ // `template` export. The public one warns in dev ("template() is a
2177
+ // compiler internal... XSS") — compiled output must never trip that
2178
+ // guard; the warning exists for *hand-written* template() calls
2179
+ // with dynamic strings. (SPRINT v0.11 C5)
1416
2180
  fgSpecifiers.push(
1417
- t.importSpecifier(t.identifier('_$template'), t.identifier('template'))
2181
+ t.importSpecifier(t.identifier('_$template'), t.identifier('_$template'))
1418
2182
  );
1419
2183
  }
1420
2184
  if (state.needsInsert) {
@@ -1442,6 +2206,36 @@ export default function whatBabelPlugin({ types: t }) {
1442
2206
  t.importSpecifier(t.identifier('_$setProp'), t.identifier('setProp'))
1443
2207
  );
1444
2208
  }
2209
+ if (state.needsMemo) {
2210
+ fgSpecifiers.push(
2211
+ t.importSpecifier(t.identifier('_$memo'), t.identifier('memo'))
2212
+ );
2213
+ }
2214
+ if (state.needsSetClass) {
2215
+ fgSpecifiers.push(
2216
+ t.importSpecifier(t.identifier('_$setClass'), t.identifier('setClass'))
2217
+ );
2218
+ }
2219
+ if (state.needsSetStyle) {
2220
+ fgSpecifiers.push(
2221
+ t.importSpecifier(t.identifier('_$setStyle'), t.identifier('setStyle'))
2222
+ );
2223
+ }
2224
+ if (state.needsSetAttr) {
2225
+ fgSpecifiers.push(
2226
+ t.importSpecifier(t.identifier('_$setAttr'), t.identifier('setAttr'))
2227
+ );
2228
+ }
2229
+ if (state.needsSetValue) {
2230
+ fgSpecifiers.push(
2231
+ t.importSpecifier(t.identifier('_$setValue'), t.identifier('setValue'))
2232
+ );
2233
+ }
2234
+ if (state.needsSetChecked) {
2235
+ fgSpecifiers.push(
2236
+ t.importSpecifier(t.identifier('_$setChecked'), t.identifier('setChecked'))
2237
+ );
2238
+ }
1445
2239
  if (state.needsCreateComponent) {
1446
2240
  fgSpecifiers.push(
1447
2241
  t.importSpecifier(t.identifier('_$createComponent'), t.identifier('_$createComponent'))
@@ -1505,58 +2299,57 @@ export default function whatBabelPlugin({ types: t }) {
1505
2299
  addCoreImports(path, t, coreSpecifiers);
1506
2300
  }
1507
2301
 
1508
- // Emit event delegation setup call if any delegated events were used
2302
+ // Emit LAZY event delegation setup if any delegated events were used.
2303
+ // Previously this was a bare module-top-level `_$delegateEvents([...])`
2304
+ // call — a side effect that (a) prevented bundlers from tree-shaking
2305
+ // modules whose components are never used, and (b) attached document
2306
+ // listeners at import time even when no component ever mounted.
2307
+ // Instead we emit a once-guarded helper that each element setup calls
2308
+ // at construction time; unused modules carry only dead declarations
2309
+ // that DCE removes. (SPRINT v0.11 C6)
1509
2310
  if (state.needsDelegation && state.delegatedEvents && state.delegatedEvents.size > 0) {
1510
2311
  const eventArray = t.arrayExpression(
1511
2312
  [...state.delegatedEvents].map(e => t.stringLiteral(e))
1512
2313
  );
1513
- path.pushContainer('body',
1514
- t.expressionStatement(
1515
- t.callExpression(t.identifier('_$delegateEvents'), [eventArray])
1516
- )
2314
+ // function _$delegate$() { if (_$delegated$) return; _$delegated$ = true; _$delegateEvents([...]); }
2315
+ const helperFn = t.functionDeclaration(
2316
+ t.identifier('_$delegate$'),
2317
+ [],
2318
+ t.blockStatement([
2319
+ t.ifStatement(
2320
+ t.identifier('_$delegated$'),
2321
+ t.returnStatement()
2322
+ ),
2323
+ t.expressionStatement(
2324
+ t.assignmentExpression('=', t.identifier('_$delegated$'), t.booleanLiteral(true))
2325
+ ),
2326
+ t.expressionStatement(
2327
+ t.callExpression(t.identifier('_$delegateEvents'), [eventArray])
2328
+ ),
2329
+ ])
1517
2330
  );
2331
+ // Unshift so `let _$delegated$ = false` executes before any
2332
+ // top-level component construction (e.g. a same-module mount()).
2333
+ path.unshiftContainer('body', [
2334
+ t.variableDeclaration('let', [
2335
+ t.variableDeclarator(t.identifier('_$delegated$'), t.booleanLiteral(false))
2336
+ ]),
2337
+ helperFn,
2338
+ ]);
1518
2339
  }
1519
2340
  }
1520
2341
  },
1521
2342
 
1522
2343
  JSXElement(path, state) {
1523
- // FIX-1: Use scope-aware signal detection instead of file-global
1524
- state.signalNames = collectSignalNamesFromScope(path);
1525
- state._pendingSetup = [];
1526
- const transformed = transformElementFineGrained(path, state);
1527
- const pending = state._pendingSetup;
1528
- state._pendingSetup = [];
1529
-
1530
- if (pending.length > 0) {
1531
- // Find the enclosing statement to hoist setup before it
1532
- let stmtPath = path;
1533
- while (stmtPath && !stmtPath.isStatement()) {
1534
- stmtPath = stmtPath.parentPath;
1535
- }
1536
- if (stmtPath && stmtPath.isStatement()) {
1537
- // Insert setup statements before the enclosing statement
1538
- for (const stmt of pending) {
1539
- stmtPath.insertBefore(stmt);
1540
- }
1541
- path.replaceWith(transformed);
1542
- } else {
1543
- // Fallback: if we can't find a statement parent, use IIFE
1544
- pending.push(t.returnStatement(transformed));
1545
- path.replaceWith(
1546
- t.callExpression(
1547
- t.arrowFunctionExpression([], t.blockStatement(pending)),
1548
- []
1549
- )
1550
- );
1551
- }
1552
- } else {
1553
- path.replaceWith(transformed);
1554
- }
2344
+ transformJsxRoot(path, state, transformElementFineGrained);
1555
2345
  },
1556
2346
 
1557
2347
  JSXFragment(path, state) {
1558
- const transformed = transformFragmentFineGrained(path, state);
1559
- path.replaceWith(transformed);
2348
+ // Fragments share the element driver: their element children push
2349
+ // `const _el$N = ...` setup into _pendingSetup, which MUST be drained
2350
+ // here (hoisted or IIFE-wrapped) or the emitted `_el$N` references
2351
+ // are never declared (ReferenceError at runtime).
2352
+ transformJsxRoot(path, state, transformFragmentFineGrained);
1560
2353
  }
1561
2354
  }
1562
2355
  };