what-compiler 0.11.6 → 0.11.8

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.
@@ -47,6 +47,36 @@ const SIGNAL_CREATORS = new Set([
47
47
  'createResource', 'useSWR', 'useQuery', 'useInfiniteQuery',
48
48
  ]);
49
49
 
50
+ // Control-flow tags the plugin lowers itself instead of emitting
51
+ // _$createComponent, so their children never travel through a factory.
52
+ const LOWERED_TAGS = new Set(['For', 'Show', 'Switch', 'Match']);
53
+
54
+ const SERVER_ACTION_SOURCES = new Set([
55
+ 'what-framework/server',
56
+ 'what-server',
57
+ 'what-server/actions',
58
+ ]);
59
+
60
+ function stableActionHash(value) {
61
+ let hash = 0xcbf29ce484222325n;
62
+ for (let i = 0; i < value.length; i++) {
63
+ hash ^= BigInt(value.charCodeAt(i));
64
+ hash = BigInt.asUintN(64, hash * 0x100000001b3n);
65
+ }
66
+ return hash.toString(36);
67
+ }
68
+
69
+ function normalizeActionFilename(filename, projectRoot) {
70
+ const clean = String(filename || '<unknown>')
71
+ .replace(/\\/g, '/')
72
+ .replace(/[?#].*$/, '');
73
+ const root = String(projectRoot || '')
74
+ .replace(/\\/g, '/')
75
+ .replace(/\/+$/, '');
76
+ if (root && clean.startsWith(`${root}/`)) return clean.slice(root.length + 1);
77
+ return clean.replace(/^\.\//, '');
78
+ }
79
+
50
80
  // Normalize JSX text per React/Babel rules:
51
81
  // - Split on newlines, treat tabs as spaces.
52
82
  // - For interior lines: trim leading and trailing horizontal whitespace.
@@ -148,6 +178,105 @@ export default function whatBabelPlugin({ types: t }) {
148
178
  return /^[A-Z]/.test(name);
149
179
  }
150
180
 
181
+ // Dotted tags (<Ctx.Provider>, <Foo.Bar.Baz>) are always components.
182
+ function isComponentElement(el) {
183
+ const name = el.openingElement ? el.openingElement.name : el.name;
184
+ if (t.isJSXMemberExpression(name)) return true;
185
+ return t.isJSXIdentifier(name) && isComponent(name.name);
186
+ }
187
+
188
+ // A tag whose binding came from a package other than What. isWhatTag is the
189
+ // stronger claim: the binding came from What, so the tag is provably ours.
190
+ function isForeignTag(name, state) {
191
+ return !!(state.foreignImports && state.foreignImports.has(name));
192
+ }
193
+
194
+ function isWhatTag(name, state) {
195
+ return !!(state.whatImports && state.whatImports.has(name));
196
+ }
197
+
198
+ // Literal <Match> arms are proof enough that a <Switch> is What's, whoever it
199
+ // was imported from: a bare specifier can be a re-export of What's control
200
+ // flow just as a relative one can, and nobody writes <Match> arms against a
201
+ // third-party <Switch>. Unlike <Show> and <For>, <Switch> has no working
202
+ // runtime path, so guessing wrong here renders the fallback forever.
203
+ function hasLiteralMatchArms(node) {
204
+ return node.children.some((child) =>
205
+ t.isJSXElement(child) && t.isJSXIdentifier(child.openingElement.name, { name: 'Match' }));
206
+ }
207
+
208
+ // JSXMemberExpression -> MemberExpression callee for _$createComponent.
209
+ function componentCallee(name) {
210
+ if (t.isJSXMemberExpression(name)) {
211
+ return t.memberExpression(componentCallee(name.object), t.identifier(name.property.name));
212
+ }
213
+ return t.identifier(name.name);
214
+ }
215
+
216
+ // --- Forwarded children on a destructured parameter ---
217
+ // `function Wrap({ children }) { return <ErrorBoundary>{children}</ErrorBoundary>; }`
218
+ // realizes the subtree while the parameter list is destructured, which is
219
+ // before ErrorBoundary is entered, so the boundary never catches and a
220
+ // provider publishes its value too late for its own subtree. Rewriting the
221
+ // pattern to a props identifier moves the read into the deferred children
222
+ // factory, where the enclosing component is already on the stack. Only a
223
+ // binding whose every reference is a component-child forward qualifies: a
224
+ // component that inspects its children still needs them realized.
225
+ function deferForwardedChildren(fnPath) {
226
+ const params = fnPath.node.params;
227
+ if (params.length !== 1 || !t.isObjectPattern(params[0])) return;
228
+ const pattern = params[0];
229
+ let index = -1;
230
+ for (let i = 0; i < pattern.properties.length; i++) {
231
+ const prop = pattern.properties[i];
232
+ if (t.isRestElement(prop)) return;
233
+ if (t.isObjectProperty(prop) && !prop.computed &&
234
+ t.isIdentifier(prop.key, { name: 'children' })) {
235
+ // A duplicate key would leave the other one destructured in the body,
236
+ // which reads the getter and defeats the deferral.
237
+ if (index !== -1 || !t.isIdentifier(prop.value)) return;
238
+ index = i;
239
+ }
240
+ }
241
+ if (index === -1) return;
242
+
243
+ const binding = fnPath.scope.getBinding(pattern.properties[index].value.name);
244
+ if (!binding || binding.constantViolations.length > 0 || binding.referencePaths.length === 0) return;
245
+ const forwarded = binding.referencePaths.every((ref) => {
246
+ const container = ref.parentPath;
247
+ if (!container || !container.isJSXExpressionContainer()) return false;
248
+ const owner = container.parentPath;
249
+ if (!owner || !owner.isJSXElement() || !isComponentElement(owner.node)) return false;
250
+ const tag = owner.node.openingElement.name;
251
+ return !(t.isJSXIdentifier(tag) && LOWERED_TAGS.has(tag.name));
252
+ });
253
+ if (!forwarded) return;
254
+
255
+ const propsId = fnPath.scope.generateUidIdentifier('props');
256
+ if (pattern.typeAnnotation) propsId.typeAnnotation = pattern.typeAnnotation;
257
+ params[0] = propsId;
258
+ const remaining = pattern.properties.filter((_, i) => i !== index);
259
+ if (remaining.length > 0) {
260
+ if (!t.isBlockStatement(fnPath.node.body)) {
261
+ fnPath.node.body = t.blockStatement([t.returnStatement(fnPath.node.body)]);
262
+ }
263
+ const moved = t.objectPattern(remaining);
264
+ fnPath.node.body.body.unshift(
265
+ t.variableDeclaration('const', [
266
+ t.variableDeclarator(moved, t.cloneNode(propsId)),
267
+ ])
268
+ );
269
+ // collectSignalNamesFromScope classifies a component's props by reading
270
+ // the parameter list, so the moved pattern has to stay visible to it or
271
+ // every sibling prop silently stops being treated as reactive.
272
+ fnPath.node._whatMovedProps = moved;
273
+ }
274
+ for (const ref of binding.referencePaths) {
275
+ ref.replaceWith(t.memberExpression(t.cloneNode(propsId), t.identifier('children')));
276
+ }
277
+ fnPath.scope.crawl();
278
+ }
279
+
151
280
  function isVoidHtmlElement(name) {
152
281
  return VOID_HTML_ELEMENTS.has(String(name).toLowerCase());
153
282
  }
@@ -173,6 +302,12 @@ export default function whatBabelPlugin({ types: t }) {
173
302
  return typeof attr.name.name === 'string' ? attr.name.name : String(attr.name.name);
174
303
  }
175
304
 
305
+ // Handler-shaped attribute name, case-insensitive. Mirrors _isEventProp in
306
+ // what-core's dom.js.
307
+ function isEventAttrName(name) {
308
+ return !!name && name.length > 2 && /^on/i.test(name);
309
+ }
310
+
176
311
  function createEventHandler(handler, modifiers) {
177
312
  if (modifiers.length === 0) return handler;
178
313
 
@@ -288,6 +423,17 @@ export default function whatBabelPlugin({ types: t }) {
288
423
  }
289
424
  }
290
425
 
426
+ function extractFromPattern(param) {
427
+ if (!t.isObjectPattern(param)) return;
428
+ for (const prop of param.properties) {
429
+ if (t.isObjectProperty(prop) && t.isIdentifier(prop.value)) {
430
+ signalNames.add(prop.value.name);
431
+ } else if (t.isRestElement(prop) && t.isIdentifier(prop.argument)) {
432
+ signalNames.add(prop.argument.name);
433
+ }
434
+ }
435
+ }
436
+
291
437
  // Walk up the scope chain using Babel's scope API.
292
438
  let scope = path.scope;
293
439
  while (scope) {
@@ -301,18 +447,13 @@ export default function whatBabelPlugin({ types: t }) {
301
447
  // scope — not once per binding. The old per-binding rescan made this
302
448
  // O(params × bindings) per scope per JSXElement. (AUDIT-2026-06-06 H2)
303
449
  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);
312
- }
313
- }
314
- }
450
+ if (fnNode) {
451
+ if (fnNode.params) {
452
+ for (const param of fnNode.params) extractFromPattern(param);
315
453
  }
454
+ // deferForwardedChildren moved this pattern out of the parameter list;
455
+ // it is still the same props destructuring and classifies the same way.
456
+ if (fnNode._whatMovedProps) extractFromPattern(fnNode._whatMovedProps);
316
457
  }
317
458
  scope = scope.parent;
318
459
  }
@@ -602,8 +743,7 @@ export default function whatBabelPlugin({ types: t }) {
602
743
  if (t.isJSXExpressionContainer(child)) return false;
603
744
  if (t.isJSXElement(child)) {
604
745
  const el = child.openingElement;
605
- const tagName = el.name.name;
606
- if (isComponent(tagName)) return false;
746
+ if (isComponentElement(child)) return false;
607
747
  for (const attr of el.attributes) {
608
748
  if (t.isJSXSpreadAttribute(attr)) return false;
609
749
  const value = attr.value;
@@ -638,7 +778,7 @@ export default function whatBabelPlugin({ types: t }) {
638
778
  const el = node.openingElement;
639
779
  const tagName = el.name.name;
640
780
 
641
- if (isComponent(tagName)) return '';
781
+ if (isComponentElement(node)) return '';
642
782
 
643
783
  let html = `<${tagName}`;
644
784
 
@@ -646,7 +786,11 @@ export default function whatBabelPlugin({ types: t }) {
646
786
  if (t.isJSXSpreadAttribute(attr)) continue;
647
787
  const name = getAttrName(attr);
648
788
  if (name === 'key') continue;
649
- if (name.startsWith('on') || name.startsWith('bind:') || name.includes('|')) continue;
789
+ // Case-insensitive: a static template is applied via innerHTML, so an
790
+ // `ONCLICK="alert(1)"` baked into the string becomes a live handler.
791
+ // Lowercase handlers are compiled separately; uppercase ones are dropped,
792
+ // matching how the runtime and SSR paths treat them.
793
+ if (isEventAttrName(name) || name.startsWith('bind:') || name.includes('|')) continue;
650
794
 
651
795
  let domName = name;
652
796
  if (name === 'className') domName = 'class';
@@ -683,7 +827,7 @@ export default function whatBabelPlugin({ types: t }) {
683
827
  html += '<!--$-->';
684
828
  }
685
829
  } else if (t.isJSXElement(child)) {
686
- if (isComponent(child.openingElement.name.name)) {
830
+ if (isComponentElement(child)) {
687
831
  html += '<!--$-->';
688
832
  } else {
689
833
  html += extractStaticHTML(child);
@@ -709,15 +853,23 @@ export default function whatBabelPlugin({ types: t }) {
709
853
  const openingElement = node.openingElement;
710
854
  const tagName = openingElement.name.name;
711
855
 
712
- // Control flow components — check before generic isComponent since they start uppercase
713
- if (tagName === 'For') {
714
- return transformForFineGrained(path, state);
856
+ // Control flow components, checked before generic isComponent since they start uppercase.
857
+ // A tag imported from somewhere other than What is that package's component,
858
+ // whatever it is called, so it goes to the component path untouched, unless
859
+ // its own arms prove otherwise.
860
+ if (!isForeignTag(tagName, state)) {
861
+ if (tagName === 'For') {
862
+ return transformForFineGrained(path, state);
863
+ }
864
+ if (tagName === 'Show') {
865
+ return transformShowFineGrained(path, state);
866
+ }
715
867
  }
716
- if (tagName === 'Show') {
717
- return transformShowFineGrained(path, state);
868
+ if (tagName === 'Switch' && (!isForeignTag(tagName, state) || hasLiteralMatchArms(node))) {
869
+ return transformSwitchFineGrained(path, state);
718
870
  }
719
871
 
720
- if (isComponent(tagName)) {
872
+ if (isComponentElement(node)) {
721
873
  return transformComponentFineGrained(path, state);
722
874
  }
723
875
 
@@ -1219,7 +1371,7 @@ export default function whatBabelPlugin({ types: t }) {
1219
1371
 
1220
1372
  if (t.isJSXElement(child)) {
1221
1373
  const childTag = child.openingElement.name.name;
1222
- if (isComponent(childTag) || childTag === 'For' || childTag === 'Show') {
1374
+ if (isComponentElement(child) || childTag === 'For' || childTag === 'Show') {
1223
1375
  entries.push({ type: 'component', child, childIndex });
1224
1376
  childIndex++;
1225
1377
  } else {
@@ -1472,7 +1624,7 @@ export default function whatBabelPlugin({ types: t }) {
1472
1624
  function transformComponentFineGrained(path, state) {
1473
1625
  const { node } = path;
1474
1626
  const openingElement = node.openingElement;
1475
- const componentName = openingElement.name.name;
1627
+ const componentRef = componentCallee(openingElement.name);
1476
1628
  const attributes = openingElement.attributes;
1477
1629
  const children = node.children;
1478
1630
 
@@ -1507,7 +1659,7 @@ export default function whatBabelPlugin({ types: t }) {
1507
1659
  state.needsIsland = true;
1508
1660
 
1509
1661
  const islandProps = [
1510
- t.objectProperty(t.identifier('component'), t.identifier(componentName)),
1662
+ t.objectProperty(t.identifier('component'), componentRef),
1511
1663
  t.objectProperty(t.identifier('mode'), t.stringLiteral(clientDirective.type)),
1512
1664
  ];
1513
1665
 
@@ -1617,19 +1769,34 @@ export default function whatBabelPlugin({ types: t }) {
1617
1769
  );
1618
1770
  }
1619
1771
 
1620
- // Transform children
1772
+ // Transform children.
1773
+ // Element children build their DOM at the call site, i.e. before the parent
1774
+ // component ever runs, so a <Provider> publishes its context value too late
1775
+ // for its own subtree. Collect the element setup and emit it behind a
1776
+ // zero-arg factory instead; the runtime calls the factory while the parent
1777
+ // is on the component stack. An expression child is deferred for the same
1778
+ // reason: it is the shape a wrapper forwards its own children through, and
1779
+ // reading it while the argument list is built happens before the component
1780
+ // it is being handed to has run. Text children have nothing to defer.
1781
+ const setupMark = state._pendingSetup ? state._pendingSetup.length : 0;
1621
1782
  const transformedChildren = [];
1783
+ let deferChildren = false;
1622
1784
  for (const child of children) {
1623
1785
  if (t.isJSXText(child)) {
1624
1786
  const text = normalizeJsxText(child.value);
1625
1787
  if (text) transformedChildren.push(t.stringLiteral(text));
1626
1788
  } else if (t.isJSXExpressionContainer(child)) {
1627
1789
  if (!t.isJSXEmptyExpression(child.expression)) {
1790
+ deferChildren = true;
1628
1791
  transformedChildren.push(child.expression);
1629
1792
  }
1630
1793
  } else if (t.isJSXElement(child)) {
1794
+ // <For>/<Show>/<Switch> lower to an inserter or a thunk (already lazy).
1795
+ const childTag = child.openingElement.name.name;
1796
+ if (childTag !== 'For' && childTag !== 'Show' && childTag !== 'Switch') deferChildren = true;
1631
1797
  transformedChildren.push(transformElementFineGrained({ node: child }, state));
1632
1798
  } else if (t.isJSXFragment(child)) {
1799
+ deferChildren = true;
1633
1800
  transformedChildren.push(transformFragmentFineGrained({ node: child }, state));
1634
1801
  }
1635
1802
  }
@@ -1650,11 +1817,20 @@ export default function whatBabelPlugin({ types: t }) {
1650
1817
  propsExpr = t.nullLiteral();
1651
1818
  }
1652
1819
 
1653
- const childrenArray = transformedChildren.length > 0
1654
- ? t.arrayExpression(transformedChildren)
1655
- : t.arrayExpression([]);
1820
+ let childrenArg = t.arrayExpression(transformedChildren);
1821
+ if (deferChildren) {
1822
+ // Hoisted element setup emitted while transforming these children belongs
1823
+ // inside the factory, not before the enclosing statement.
1824
+ const setup = state._pendingSetup ? state._pendingSetup.splice(setupMark) : [];
1825
+ childrenArg = t.arrowFunctionExpression(
1826
+ [],
1827
+ setup.length > 0
1828
+ ? t.blockStatement([...setup, t.returnStatement(childrenArg)])
1829
+ : childrenArg
1830
+ );
1831
+ }
1656
1832
 
1657
- return t.callExpression(t.identifier('_$createComponent'), [t.identifier(componentName), propsExpr, childrenArray]);
1833
+ return t.callExpression(t.identifier('_$createComponent'), [componentRef, propsExpr, childrenArg]);
1658
1834
  }
1659
1835
 
1660
1836
  function transformForFineGrained(path, state) {
@@ -1785,32 +1961,7 @@ export default function whatBabelPlugin({ types: t }) {
1785
1961
  // () => { const _v = <condition>; return _v ? <consequent> : <alternate>; }
1786
1962
  // Hoisting into a local prevents double-evaluation of the `when` signal
1787
1963
  // (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
- }
1964
+ let condition = buildWhenCondition(whenExpr, state);
1814
1965
 
1815
1966
  const vId = path.scope
1816
1967
  ? path.scope.generateUidIdentifier('v')
@@ -1861,6 +2012,183 @@ export default function whatBabelPlugin({ types: t }) {
1861
2012
  ]));
1862
2013
  }
1863
2014
 
2015
+ // A `when` prop's shape determines how the condition is formed:
2016
+ // - call expression → use as-is when={cond()}
2017
+ // - arrow w/ expression body → use the body when={() => x > 5}
2018
+ // - identifier that looks like a signal/import when={isOpen}
2019
+ // → invoke it as accessor: isOpen()
2020
+ // - anything else (member, literal, logical, etc.) when={user.isAdmin}
2021
+ // → use the raw expression. Do NOT invoke it:
2022
+ // non-functions would throw at runtime.
2023
+ function buildWhenCondition(whenExpr, state) {
2024
+ if (t.isCallExpression(whenExpr)) return whenExpr;
2025
+ if (t.isArrowFunctionExpression(whenExpr) && t.isExpression(whenExpr.body)) return whenExpr.body;
2026
+ if (
2027
+ t.isIdentifier(whenExpr) &&
2028
+ (
2029
+ (state.signalNames && isSignalIdentifier(whenExpr.name, state.signalNames)) ||
2030
+ (state.importedIdentifiers && state.importedIdentifiers.has(whenExpr.name))
2031
+ )
2032
+ ) {
2033
+ return t.callExpression(whenExpr, []);
2034
+ }
2035
+ return whenExpr;
2036
+ }
2037
+
2038
+ function collectControlFlowContent(children, state) {
2039
+ const parts = [];
2040
+ for (const child of children) {
2041
+ if (t.isJSXText(child)) {
2042
+ const text = normalizeJsxText(child.value);
2043
+ if (text) parts.push(t.stringLiteral(text));
2044
+ } else if (t.isJSXExpressionContainer(child)) {
2045
+ if (!t.isJSXEmptyExpression(child.expression)) parts.push(child.expression);
2046
+ } else if (t.isJSXElement(child)) {
2047
+ parts.push(transformElementFineGrained({ node: child }, state));
2048
+ } else if (t.isJSXFragment(child)) {
2049
+ parts.push(transformFragmentFineGrained({ node: child }, state));
2050
+ }
2051
+ }
2052
+ if (parts.length === 0) return t.nullLiteral();
2053
+ if (parts.length === 1) return parts[0];
2054
+ return t.arrayExpression(parts);
2055
+ }
2056
+
2057
+ function transformSwitchFineGrained(path, state) {
2058
+ // <Switch fallback={alt}><Match when={a}>A</Match><Match when={b}>B</Match></Switch>
2059
+ // → () => a ? A : b ? B : alt
2060
+ //
2061
+ // Same reactive-thunk shape <Show> lowers to, for the same reason: the
2062
+ // component runs once, so the arm has to be picked inside a thunk that
2063
+ // insert() re-evaluates. The runtime Switch matches on Match marker vnodes,
2064
+ // which _$createComponent never produces (calling Match instead recurses),
2065
+ // so compiled JSX has no working runtime path.
2066
+ //
2067
+ // A <Switch> is provably What's when either its binding was imported from
2068
+ // What or it has literal <Match> arms; anything else with the same tag name
2069
+ // is somebody else's component and goes to the component path untouched.
2070
+ // Once it is provably ours, a shape whose arms cannot be read statically has
2071
+ // nowhere to go, since the runtime component would render its fallback and
2072
+ // nothing else forever. Fail that build rather than emit a silent blank.
2073
+ const { node } = path;
2074
+
2075
+ // A <Switch> that got here on its arms alone came from the same foreign
2076
+ // specifier its arms did, so its <Match> is ours by the same reasoning.
2077
+ const armsProveIt = isForeignTag(node.openingElement.name.name, state);
2078
+ const isMatch = (child) =>
2079
+ t.isJSXElement(child) && t.isJSXIdentifier(child.openingElement.name, { name: 'Match' }) &&
2080
+ (armsProveIt || !isForeignTag('Match', state));
2081
+ if (!node.children.some(isMatch) && !isWhatTag(node.openingElement.name.name, state)) {
2082
+ return transformComponentFineGrained(path, state);
2083
+ }
2084
+
2085
+ const unsupported = (why) => {
2086
+ const message =
2087
+ `<Switch> ${why}. The compiler needs to read its arms statically. ` +
2088
+ 'Write them out: <Switch fallback={...}><Match when={a}>…</Match><Match when={b}>…</Match></Switch>';
2089
+ throw path.buildCodeFrameError
2090
+ ? path.buildCodeFrameError(message)
2091
+ : new Error(`[what-compiler] ${message}`);
2092
+ };
2093
+
2094
+ let fallbackExpr = null;
2095
+ for (const attr of node.openingElement.attributes) {
2096
+ if (t.isJSXSpreadAttribute(attr)) unsupported('cannot take a spread attribute');
2097
+ if (getAttrName(attr) === 'fallback') fallbackExpr = getAttributeValue(attr.value);
2098
+ }
2099
+
2100
+ const arms = [];
2101
+ for (const child of node.children) {
2102
+ if (t.isJSXText(child)) {
2103
+ if (normalizeJsxText(child.value)) unsupported('cannot mix text with its <Match> arms');
2104
+ continue;
2105
+ }
2106
+ if (t.isJSXExpressionContainer(child)) {
2107
+ if (t.isJSXEmptyExpression(child.expression)) continue;
2108
+ unsupported('cannot read its arms from an expression child');
2109
+ }
2110
+ if (!isMatch(child)) {
2111
+ unsupported('cannot mix other elements with its <Match> arms');
2112
+ }
2113
+ arms.push(child);
2114
+ }
2115
+
2116
+ if (arms.length === 0) unsupported('has no <Match> arms');
2117
+
2118
+ if (!state._pendingSetup) state._pendingSetup = [];
2119
+ const branches = [];
2120
+
2121
+ for (const arm of arms) {
2122
+ let whenExpr = null;
2123
+ for (const attr of arm.openingElement.attributes) {
2124
+ if (t.isJSXAttribute(attr) && getAttrName(attr) === 'when') whenExpr = getAttributeValue(attr.value);
2125
+ }
2126
+ if (!whenExpr) {
2127
+ unsupported('has a <Match> with no "when" prop');
2128
+ }
2129
+
2130
+ // An arm's element setup belongs inside the arm. Hoisting it next to the
2131
+ // thunk, the way a lone element does, would build every arm's DOM on the
2132
+ // way to picking one.
2133
+ const setupMark = state._pendingSetup.length;
2134
+ let content = collectControlFlowContent(arm.children, state);
2135
+ const setup = state._pendingSetup.splice(setupMark);
2136
+ if (setup.length > 0) {
2137
+ content = t.callExpression(
2138
+ t.arrowFunctionExpression([], t.blockStatement([...setup, t.returnStatement(content)])),
2139
+ []
2140
+ );
2141
+ }
2142
+
2143
+ branches.push([buildWhenCondition(whenExpr, state), content]);
2144
+ }
2145
+
2146
+ const anyReactive = branches.some(([condition]) =>
2147
+ isPotentiallyReactive(condition, state.signalNames, state.importedIdentifiers));
2148
+
2149
+ const fallback = fallbackExpr || t.nullLiteral();
2150
+
2151
+ if (!anyReactive) {
2152
+ let expr = fallback;
2153
+ for (let i = branches.length - 1; i >= 0; i--) {
2154
+ expr = t.conditionalExpression(branches[i][0], branches[i][1], expr);
2155
+ }
2156
+ return t.arrowFunctionExpression([], expr);
2157
+ }
2158
+
2159
+ // ONE memo over the whole chain, not one per arm. _$memo evaluates its body
2160
+ // immediately, so a memo per arm would evaluate every arm's `when` up front,
2161
+ // while the runtime Switch stops at the first match. A single memo that
2162
+ // resolves to the winning arm's index keeps that short-circuit (`when` props
2163
+ // after the first truthy one are never read) and still gates re-rendering on
2164
+ // the selected arm actually changing.
2165
+ let selector = t.numericLiteral(-1);
2166
+ for (let i = branches.length - 1; i >= 0; i--) {
2167
+ selector = t.conditionalExpression(branches[i][0], t.numericLiteral(i), selector);
2168
+ }
2169
+
2170
+ const armId = state.nextMemoId();
2171
+ state.needsMemo = true;
2172
+ state._pendingSetup.push(
2173
+ t.variableDeclaration('const', [
2174
+ t.variableDeclarator(
2175
+ t.identifier(armId),
2176
+ t.callExpression(t.identifier('_$memo'), [t.arrowFunctionExpression([], selector)])
2177
+ )
2178
+ ])
2179
+ );
2180
+
2181
+ let expr = fallback;
2182
+ for (let i = branches.length - 1; i >= 0; i--) {
2183
+ expr = t.conditionalExpression(
2184
+ t.binaryExpression('===', t.callExpression(t.identifier(armId), []), t.numericLiteral(i)),
2185
+ branches[i][1],
2186
+ expr
2187
+ );
2188
+ }
2189
+ return t.arrowFunctionExpression([], expr);
2190
+ }
2191
+
1864
2192
  // A fragment-as-root returns an array (or single value) that the runtime
1865
2193
  // mounts element-by-element (createDOM / insert). Unlike the element-child
1866
2194
  // path there's no host element to _$insert into with a marker, so reactive
@@ -2042,6 +2370,114 @@ export default function whatBabelPlugin({ types: t }) {
2042
2370
  }
2043
2371
  }
2044
2372
 
2373
+ function actionBindingName(callPath) {
2374
+ const parent = callPath.parentPath;
2375
+ if (parent?.isVariableDeclarator() && t.isIdentifier(parent.node.id)) {
2376
+ return parent.node.id.name;
2377
+ }
2378
+ if (parent?.isAssignmentExpression() && t.isIdentifier(parent.node.left)) {
2379
+ return parent.node.left.name;
2380
+ }
2381
+ if (parent?.isExportDefaultDeclaration()) return 'default';
2382
+ if (parent?.isObjectProperty() && !parent.node.computed) {
2383
+ if (t.isIdentifier(parent.node.key)) return parent.node.key.name;
2384
+ if (t.isStringLiteral(parent.node.key)) return parent.node.key.value;
2385
+ }
2386
+ return null;
2387
+ }
2388
+
2389
+ function isServerActionCall(callPath, state) {
2390
+ const callee = callPath.node.callee;
2391
+ if (t.isIdentifier(callee)) return state.serverActionBindings.has(callee.name);
2392
+ return t.isMemberExpression(callee)
2393
+ && !callee.computed
2394
+ && t.isIdentifier(callee.object)
2395
+ && t.isIdentifier(callee.property, { name: 'action' })
2396
+ && state.serverActionNamespaces.has(callee.object.name);
2397
+ }
2398
+
2399
+ function staticActionId(optionsNode) {
2400
+ if (!t.isObjectExpression(optionsNode)) return { property: null, value: null };
2401
+ for (const property of optionsNode.properties) {
2402
+ if (!t.isObjectProperty(property)) continue;
2403
+ const key = t.isIdentifier(property.key) && !property.computed ? property.key.name
2404
+ : t.isStringLiteral(property.key) ? property.key.value
2405
+ : null;
2406
+ if (key !== 'id') continue;
2407
+ if (t.isStringLiteral(property.value)) return { property, value: property.value.value };
2408
+ if (t.isTemplateLiteral(property.value) && property.value.expressions.length === 0) {
2409
+ return { property, value: property.value.quasis[0]?.value.cooked || '' };
2410
+ }
2411
+ return { property, value: null };
2412
+ }
2413
+ return { property: null, value: null };
2414
+ }
2415
+
2416
+ function registerActionId(callPath, state, id, bindingName, relativeFilename) {
2417
+ const loc = callPath.node.loc?.start || { line: 0, column: 0 };
2418
+ const source = `${relativeFilename}:${loc.line}:${loc.column + 1}`;
2419
+ const key = `${relativeFilename}#${bindingName}`;
2420
+ const existing = state.serverActionIds.get(id);
2421
+ if (existing) {
2422
+ throw callPath.buildCodeFrameError(
2423
+ `Duplicate server action ID "${id}". ` +
2424
+ `First declared by "${existing.bindingName}" at ${existing.source}; ` +
2425
+ `conflicts with "${bindingName}" at ${source}.`
2426
+ );
2427
+ }
2428
+ const metadata = { id, key, bindingName, filename: relativeFilename, source, loc };
2429
+ state.serverActionIds.set(id, metadata);
2430
+ if (typeof state.opts?.onActionId === 'function') {
2431
+ try {
2432
+ state.opts.onActionId(metadata);
2433
+ } catch (error) {
2434
+ throw callPath.buildCodeFrameError(error instanceof Error ? error.message : String(error));
2435
+ }
2436
+ }
2437
+ }
2438
+
2439
+ function transformServerAction(callPath, state) {
2440
+ if (!isServerActionCall(callPath, state)) return;
2441
+
2442
+ const bindingName = actionBindingName(callPath);
2443
+ if (!bindingName) {
2444
+ if (!state.opts?.production) {
2445
+ console.warn(
2446
+ `[what-compiler] Could not derive a stable server action ID at ` +
2447
+ `${state.serverActionFilename}:${callPath.node.loc?.start.line || 0}. ` +
2448
+ 'Assign action(...) to a named local or export, or pass { id } explicitly.'
2449
+ );
2450
+ }
2451
+ return;
2452
+ }
2453
+
2454
+ const optionsNode = callPath.node.arguments[1];
2455
+ const explicit = staticActionId(optionsNode);
2456
+ if (explicit.property) {
2457
+ if (explicit.value !== null) {
2458
+ registerActionId(callPath, state, explicit.value, bindingName, state.serverActionFilename);
2459
+ }
2460
+ return;
2461
+ }
2462
+
2463
+ const id = `wa1_${stableActionHash(`${state.serverActionFilename}#${bindingName}`)}`;
2464
+ registerActionId(callPath, state, id, bindingName, state.serverActionFilename);
2465
+ const idProperty = t.objectProperty(t.identifier('id'), t.stringLiteral(id));
2466
+
2467
+ if (!optionsNode) {
2468
+ callPath.node.arguments.push(t.objectExpression([idProperty]));
2469
+ } else if (t.isObjectExpression(optionsNode)) {
2470
+ optionsNode.properties.unshift(idProperty);
2471
+ } else {
2472
+ // Put the generated default before the caller's dynamic options so a
2473
+ // runtime `options.id` remains authoritative.
2474
+ callPath.node.arguments[1] = t.objectExpression([
2475
+ idProperty,
2476
+ t.spreadElement(optionsNode),
2477
+ ]);
2478
+ }
2479
+ }
2480
+
2045
2481
  // =====================================================
2046
2482
  // Plugin entry
2047
2483
  // =====================================================
@@ -2080,6 +2516,14 @@ export default function whatBabelPlugin({ types: t }) {
2080
2516
  state.nextVarId = () => `_el$${state._varCounter++}`;
2081
2517
  state.nextMemoId = () => `_c$${state._memoCounter++}`;
2082
2518
 
2519
+ state.serverActionBindings = new Set();
2520
+ state.serverActionNamespaces = new Set();
2521
+ state.serverActionIds = new Map();
2522
+ state.serverActionFilename = normalizeActionFilename(
2523
+ state.file?.opts?.filename,
2524
+ state.opts?.projectRoot || state.file?.opts?.cwd
2525
+ );
2526
+
2083
2527
  // Collect signal names for smart reactivity detection
2084
2528
  state.signalNames = new Set();
2085
2529
 
@@ -2089,14 +2533,22 @@ export default function whatBabelPlugin({ types: t }) {
2089
2533
  // (user stores), or functions matching use*/create* naming conventions.
2090
2534
  // This prevents over-wrapping of utility imports (lodash, etc.).
2091
2535
  state.importedIdentifiers = new Set();
2536
+ // <Switch>, <Show>, <For> and <Match> are dispatched on the bare tag
2537
+ // name, and Ant Design, Headless UI and MUI all ship a <Switch>.
2538
+ // Recording which package a tag was imported from tells What's own
2539
+ // control flow apart from a third party's by provenance instead.
2540
+ state.whatImports = new Set();
2541
+ state.foreignImports = new Set();
2092
2542
  for (const node of path.node.body) {
2093
2543
  if (t.isImportDeclaration(node)) {
2094
2544
  const source = node.source.value;
2095
- const isReactiveSource =
2545
+ const isWhatSource =
2096
2546
  source === 'what-framework' ||
2097
2547
  source.startsWith('what-framework/') ||
2098
2548
  source === 'what-core' ||
2099
- source.startsWith('what-core/') ||
2549
+ source.startsWith('what-core/');
2550
+ const isReactiveSource =
2551
+ isWhatSource ||
2100
2552
  source.startsWith('./') ||
2101
2553
  source.startsWith('../');
2102
2554
 
@@ -2116,12 +2568,34 @@ export default function whatBabelPlugin({ types: t }) {
2116
2568
  if (isReactiveSource || /^(use|create)[A-Z]/.test(localName)) {
2117
2569
  state.importedIdentifiers.add(localName);
2118
2570
  }
2571
+ // A relative import can be a re-export of What's own control
2572
+ // flow, so only a bare package specifier counts as foreign.
2573
+ if (isWhatSource) state.whatImports.add(localName);
2574
+ else if (!source.startsWith('.')) state.foreignImports.add(localName);
2575
+ }
2576
+
2577
+ if (SERVER_ACTION_SOURCES.has(source)) {
2578
+ if (
2579
+ t.isImportSpecifier(spec)
2580
+ && t.isIdentifier(spec.imported, { name: 'action' })
2581
+ && t.isIdentifier(spec.local)
2582
+ ) {
2583
+ state.serverActionBindings.add(spec.local.name);
2584
+ } else if (t.isImportNamespaceSpecifier(spec) && t.isIdentifier(spec.local)) {
2585
+ state.serverActionNamespaces.add(spec.local.name);
2586
+ }
2119
2587
  }
2120
2588
  }
2121
2589
  }
2122
2590
  }
2123
2591
 
2124
2592
  path.traverse({
2593
+ Function(fnPath) {
2594
+ deferForwardedChildren(fnPath);
2595
+ },
2596
+ CallExpression(callPath) {
2597
+ transformServerAction(callPath, state);
2598
+ },
2125
2599
  VariableDeclarator(declPath) {
2126
2600
  const init = declPath.node.init;
2127
2601
  if (!init || !t.isCallExpression(init)) return;