what-compiler 0.13.4 → 0.13.6

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.
@@ -41,6 +41,20 @@ const SAFE_GLOBAL_CALLS = new Set([
41
41
  'console', 'RegExp',
42
42
  ]);
43
43
 
44
+ // Built-in prototype methods that convert or copy a value. A zero-argument call
45
+ // is otherwise assumed to be a signal read (see isPotentiallyReactive); these
46
+ // names are the ones JavaScript itself defines, so they never are, and they are
47
+ // common enough in JSX that wrapping them would be pure cost.
48
+ const PURE_BUILTIN_METHODS = new Set([
49
+ 'toString', 'toLocaleString', 'valueOf', 'toJSON',
50
+ 'toUpperCase', 'toLowerCase', 'toLocaleUpperCase', 'toLocaleLowerCase',
51
+ 'trim', 'trimStart', 'trimEnd',
52
+ 'toFixed', 'toPrecision', 'toExponential',
53
+ 'toISOString', 'toUTCString', 'toDateString', 'toTimeString',
54
+ 'toLocaleDateString', 'toLocaleTimeString',
55
+ 'slice', 'reverse', 'sort', 'flat', 'concat', 'join',
56
+ ]);
57
+
44
58
  // Known signal-creating functions
45
59
  const SIGNAL_CREATORS = new Set([
46
60
  'useSignal', 'signal', 'computed', 'useComputed', 'useState', 'useReducer',
@@ -57,6 +71,17 @@ const SERVER_ACTION_SOURCES = new Set([
57
71
  'what-server/actions',
58
72
  ]);
59
73
 
74
+ // A bare specifier that resolves to this framework, entry point included:
75
+ // 'what-framework', 'what-framework/render', 'what-core', 'what-core/render'.
76
+ // Used to decide whether an imported binding is provably ours before the plugin
77
+ // changes how a call to it is compiled.
78
+ function isWhatModuleSource(source) {
79
+ return source === 'what-framework'
80
+ || source.startsWith('what-framework/')
81
+ || source === 'what-core'
82
+ || source.startsWith('what-core/');
83
+ }
84
+
60
85
  function stableActionHash(value) {
61
86
  let hash = 0xcbf29ce484222325n;
62
87
  for (let i = 0; i < value.length; i++) {
@@ -242,6 +267,67 @@ export default function whatBabelPlugin({ types: t }) {
242
267
  t.isJSXElement(child) && t.isJSXIdentifier(child.openingElement.name, { name: 'Match' }));
243
268
  }
244
269
 
270
+ // --- The JSX handed straight to hydrate() ---
271
+ //
272
+ // hydrate() can only adopt the server's markup from an UNBUILT tree: a node
273
+ // that has already been built has its bindings wired to itself, so hydrateNode
274
+ // inserts it and lets the trim delete everything the server sent. The
275
+ // documented client entry, `hydrate(<App />, el)`, lowered to
276
+ // `_$createComponent(App, ...)`, which runs App and builds its DOM before
277
+ // hydrate() is even called — so every compiled app that hydrated threw its
278
+ // server render away and re-rendered from scratch, silently in production
279
+ // where the warning is stripped.
280
+ //
281
+ // Only the first argument is the tree; the second is the container. And only a
282
+ // binding that provably resolves to OUR hydrate qualifies, because rewriting a
283
+ // call to somebody else's `hydrate` would change what their code means.
284
+ function isHydrateRootArgument(path) {
285
+ // transformElementFineGrained is also called with a bare `{ node }` stand-in
286
+ // when it recurses into children, and a child is never the hydrate root.
287
+ const parent = path.parentPath;
288
+ if (!parent || typeof parent.isCallExpression !== 'function' || !parent.isCallExpression()) {
289
+ return false;
290
+ }
291
+ if (parent.node.arguments[0] !== path.node) return false;
292
+ return isWhatHydrateCallee(parent.node.callee, parent.scope);
293
+ }
294
+
295
+ // Resolve the callee through Babel's scope rather than by name, so a local
296
+ // `function hydrate()` or `const hydrate = ...` that shadows the import is left
297
+ // alone. getBinding returns the NEAREST binding, which is exactly the shadowing
298
+ // rule the language itself applies. An unresolved (global) `hydrate` is not
299
+ // ours either, so it stays untouched too.
300
+ function isWhatHydrateCallee(callee, scope) {
301
+ // `ns.hydrate(...)` after `import * as ns from 'what-framework'`.
302
+ if (t.isMemberExpression(callee) && !callee.computed
303
+ && t.isIdentifier(callee.property, { name: 'hydrate' })
304
+ && t.isIdentifier(callee.object)) {
305
+ return importedFrom(scope.getBinding(callee.object.name), t.isImportNamespaceSpecifier, null);
306
+ }
307
+ if (!t.isIdentifier(callee)) return false;
308
+ // `import { hydrate }` and `import { hydrate as boot }` alike: the binding
309
+ // remembers the name that was IMPORTED, which is the one that matters.
310
+ return importedFrom(scope.getBinding(callee.name), t.isImportSpecifier, 'hydrate');
311
+ }
312
+
313
+ // A binding is ours when it came from an import declaration of a What module
314
+ // through the expected specifier kind, optionally carrying a required imported
315
+ // name (`hydrate` for a named import, none for a namespace).
316
+ function importedFrom(binding, isSpecifier, importedName) {
317
+ if (!binding || binding.kind !== 'module' || !binding.path) return false;
318
+ const spec = binding.path.node;
319
+ if (!isSpecifier(spec)) return false;
320
+ if (importedName !== null) {
321
+ const imported = spec.imported;
322
+ const name = t.isIdentifier(imported) ? imported.name
323
+ : t.isStringLiteral(imported) ? imported.value
324
+ : null;
325
+ if (name !== importedName) return false;
326
+ }
327
+ const decl = binding.path.parent;
328
+ return t.isImportDeclaration(decl) && isWhatModuleSource(decl.source.value);
329
+ }
330
+
245
331
  // JSXMemberExpression -> MemberExpression callee for _$createComponent.
246
332
  function componentCallee(name) {
247
333
  if (t.isJSXMemberExpression(name)) {
@@ -331,6 +417,27 @@ export default function whatBabelPlugin({ types: t }) {
331
417
  return attrName;
332
418
  }
333
419
 
420
+ // Is this attribute overwritten by a later one naming the same DOM property?
421
+ //
422
+ // `<div id={a} id="b"/>` is legal JSX and means id="b": the last writer of a
423
+ // name wins, exactly as it does in the object literal the h() spelling
424
+ // builds. The template could not express that, because a repeated attribute
425
+ // in an HTML string keeps the FIRST value and a dynamic attribute is applied
426
+ // after the template regardless of where it was written. Dropping the
427
+ // shadowed one leaves a single writer per name, so written order decides.
428
+ //
429
+ // A later SPREAD never shadows: it overrides only the names it actually
430
+ // carries, which is not knowable here and is handled by ordering instead.
431
+ function isShadowedAttr(attributes, index) {
432
+ const name = normalizeAttrName(getAttrName(attributes[index]));
433
+ for (let i = index + 1; i < attributes.length; i += 1) {
434
+ const later = attributes[i];
435
+ if (t.isJSXSpreadAttribute(later)) continue;
436
+ if (normalizeAttrName(getAttrName(later)) === name) return true;
437
+ }
438
+ return false;
439
+ }
440
+
334
441
  // Safely extract attribute name, handling JSXNamespacedName (e.g., client:idle, bind:value)
335
442
  function getAttrName(attr) {
336
443
  if (t.isJSXNamespacedName(attr.name)) {
@@ -339,6 +446,20 @@ export default function whatBabelPlugin({ types: t }) {
339
446
  return typeof attr.name.name === 'string' ? attr.name.name : String(attr.name.name);
340
447
  }
341
448
 
449
+ // The key half of a prop in an emitted object literal. A JSX attribute name is
450
+ // frequently NOT a valid JavaScript identifier — `data-x`, `aria-label` and
451
+ // every namespaced name contain characters an identifier cannot — so anything
452
+ // that does not match has to be quoted.
453
+ //
454
+ // Shared rather than inlined because babel's builders do not validate: handing
455
+ // t.identifier() a name with a dash produces `{data-x: "1"}` in the output and
456
+ // no error anywhere, so the mistake surfaces as a parse failure in generated
457
+ // code. Two call sites had their own copy of this test and one of them was
458
+ // missing it entirely.
459
+ function propKey(name) {
460
+ return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? t.identifier(name) : t.stringLiteral(name);
461
+ }
462
+
342
463
  // Handler-shaped attribute name, case-insensitive. Mirrors _isEventProp in
343
464
  // what-core's dom.js.
344
465
  function isEventAttrName(name) {
@@ -520,6 +641,16 @@ export default function whatBabelPlugin({ types: t }) {
520
641
  // backward compat (used in collectSignalNames calls)". Nothing called it;
521
642
  // every call site uses collectSignalNamesFromScope directly.
522
643
 
644
+ // `x.toUpperCase()` and friends, listed in PURE_BUILTIN_METHODS. Only the method
645
+ // name is checked: whether the RECEIVER is reactive is decided separately, so
646
+ // `user().name.trim()` stays reactive through its object.
647
+ function isPureBuiltinMethodCall(expr) {
648
+ return t.isMemberExpression(expr.callee)
649
+ && !expr.callee.computed
650
+ && t.isIdentifier(expr.callee.property)
651
+ && PURE_BUILTIN_METHODS.has(expr.callee.property.name);
652
+ }
653
+
523
654
  // Check if a call expression is a safe (non-reactive) global call
524
655
  function isSafeGlobalCall(expr) {
525
656
  if (!t.isCallExpression(expr)) return false;
@@ -599,6 +730,17 @@ export default function whatBabelPlugin({ types: t }) {
599
730
  if (isSafeGlobalCall(expr)) {
600
731
  return expr.arguments.some(arg => isPotentiallyReactive(arg, signalNames, importedIds));
601
732
  }
733
+ // Any other zero-argument call has the shape of an accessor read, so it
734
+ // is assumed to be one. `props.count()`, a plain parameter `count()` and
735
+ // `s()` from `const s = useThing()` are all opaque to the compiler: it
736
+ // knows only the names it watched being created. Assuming the narrow way
737
+ // round meant those three froze silently while the docs promised
738
+ // `{count()}` was reactive. Assuming this way round costs, when the call
739
+ // turns out to be constant, one effect that runs once and never again.
740
+ if (expr.arguments.length === 0 && !isPureBuiltinMethodCall(expr)) {
741
+ return true;
742
+ }
743
+
602
744
  // Unknown call — check if callee or args contain signal reads
603
745
  if (t.isIdentifier(expr.callee)) {
604
746
  // Could be a function that reads signals internally
@@ -819,6 +961,7 @@ export default function whatBabelPlugin({ types: t }) {
819
961
  if (!t.isArrowFunctionExpression(mapFn) && !t.isFunctionExpression(mapFn)) return null;
820
962
 
821
963
  // Get the map callback's return expression
964
+ /** @type {any} */
822
965
  let returnExpr = null;
823
966
  if (t.isArrowFunctionExpression(mapFn)) {
824
967
  if (t.isExpression(mapFn.body)) {
@@ -837,6 +980,7 @@ export default function whatBabelPlugin({ types: t }) {
837
980
  // Check if the return is JSX with a `key` prop
838
981
  if (!t.isJSXElement(returnExpr)) return null;
839
982
  const attrs = returnExpr.openingElement.attributes;
983
+ /** @type {any} */
840
984
  let keyAttr = null;
841
985
  for (const attr of attrs) {
842
986
  if (t.isJSXAttribute(attr) && getAttrName(attr) === 'key') {
@@ -983,6 +1127,37 @@ export default function whatBabelPlugin({ types: t }) {
983
1127
  return t.isJSXExpressionContainer(attr.value);
984
1128
  }
985
1129
 
1130
+ // The child slots a fragment contributes to its parent, flattened.
1131
+ //
1132
+ // A fragment renders no node of its own, so each of its children takes a
1133
+ // sibling position in the parent exactly as if the fragment were not written.
1134
+ // extractStaticHTML() and applyDynamicChildren() both read this list, so the
1135
+ // markers baked into the template and the inserts emitted against them can
1136
+ // never disagree about how many positions the fragment occupies.
1137
+ //
1138
+ // Nested fragments flatten too: `<><>a</>b</>` is two slots in the parent.
1139
+ // Whitespace-only text and `{/* comment */}` contribute nothing, matching how
1140
+ // the same children are treated directly under an element.
1141
+ function fragmentSlots(fragmentNode, out = []) {
1142
+ for (const child of fragmentNode.children) {
1143
+ if (t.isJSXText(child)) {
1144
+ const text = normalizeJsxText(child.value);
1145
+ if (text) out.push({ kind: 'text', text });
1146
+ continue;
1147
+ }
1148
+ if (t.isJSXFragment(child)) {
1149
+ fragmentSlots(child, out);
1150
+ continue;
1151
+ }
1152
+ if (t.isJSXExpressionContainer(child)) {
1153
+ if (!t.isJSXEmptyExpression(child.expression)) out.push({ kind: 'expression', child });
1154
+ continue;
1155
+ }
1156
+ if (t.isJSXElement(child)) out.push({ kind: 'element', child });
1157
+ }
1158
+ return out;
1159
+ }
1160
+
986
1161
  // Extract static HTML from JSX element for template()
987
1162
  function extractStaticHTML(node) {
988
1163
  if (t.isJSXText(node)) {
@@ -1004,8 +1179,19 @@ export default function whatBabelPlugin({ types: t }) {
1004
1179
 
1005
1180
  let html = `<${tagName}`;
1006
1181
 
1007
- for (const attr of el.attributes) {
1182
+ // A static attribute written AFTER a spread cannot be baked into the
1183
+ // template. The template is applied first and the spread runs against the
1184
+ // finished element, so baking would let the spread overwrite an attribute
1185
+ // the author pinned after it: `<div {...{id:'a'}} id="b"/>` rendered id="a"
1186
+ // while the h() spelling of the same tree renders id="b". Everything from
1187
+ // the first spread onward is emitted as an ordered runtime set instead, in
1188
+ // applyDynamicAttrs.
1189
+ const firstSpread = el.attributes.findIndex(a => t.isJSXSpreadAttribute(a));
1190
+
1191
+ for (const [attrIndex, attr] of el.attributes.entries()) {
1008
1192
  if (t.isJSXSpreadAttribute(attr)) continue;
1193
+ if (firstSpread !== -1 && attrIndex > firstSpread) continue;
1194
+ if (isShadowedAttr(el.attributes, attrIndex)) continue;
1009
1195
  const name = getAttrName(attr);
1010
1196
  if (name === 'key') continue;
1011
1197
  // Case-insensitive: a static template is applied via innerHTML, so an
@@ -1055,11 +1241,14 @@ export default function whatBabelPlugin({ types: t }) {
1055
1241
  html += extractStaticHTML(child);
1056
1242
  }
1057
1243
  } else if (t.isJSXFragment(child)) {
1058
- // One marker for the whole fragment: its children all insert before it,
1059
- // in order, which puts them exactly where the fragment sits. Without
1060
- // this a fragment child occupied no position at all, so its content was
1061
- // appended to the end of the parent instead of staying in place.
1062
- html += '<!--$-->';
1244
+ // One marker PER fragment slot, not one for the whole fragment. A
1245
+ // fragment is not a DOM boundary, so its children are siblings of the
1246
+ // fragment's siblings and each needs its own anchor to keep its place.
1247
+ // With a single shared anchor the order held at mount and then broke on
1248
+ // the first re-run: reconcileInsert re-places a reactive region before
1249
+ // that anchor, which puts it after every sibling that had been inserted
1250
+ // before the same anchor earlier.
1251
+ html += '<!--$-->'.repeat(fragmentSlots(child).length);
1063
1252
  }
1064
1253
  }
1065
1254
 
@@ -1274,8 +1463,15 @@ export default function whatBabelPlugin({ types: t }) {
1274
1463
  );
1275
1464
  }
1276
1465
 
1277
- for (const attr of attributes) {
1466
+ // Static attributes before the first spread are already in the template.
1467
+ // Ones after it are not, because the template is applied before _$spread
1468
+ // runs and would lose to it; they are set here instead, in source order, so
1469
+ // the last write for a key wins exactly as it does in the h() call.
1470
+ let seenSpread = false;
1471
+
1472
+ for (const [attrIndex, attr] of attributes.entries()) {
1278
1473
  if (t.isJSXSpreadAttribute(attr)) {
1474
+ seenSpread = true;
1279
1475
  state.needsSpread = true;
1280
1476
  statements.push(
1281
1477
  t.expressionStatement(
@@ -1285,6 +1481,10 @@ export default function whatBabelPlugin({ types: t }) {
1285
1481
  continue;
1286
1482
  }
1287
1483
 
1484
+ // A later attribute of the same name is the only writer that matters.
1485
+ // Emitting this one too would apply it after the template and let it win.
1486
+ if (isShadowedAttr(attributes, attrIndex)) continue;
1487
+
1288
1488
  const attrName = getAttrName(attr);
1289
1489
 
1290
1490
  // Strip key prop — WhatFW has no virtual DOM, so key is meaningless (issue #6)
@@ -1484,6 +1684,19 @@ export default function whatBabelPlugin({ types: t }) {
1484
1684
  // Static expression (no signal calls) — set once
1485
1685
  statements.push(t.expressionStatement(buildSetPropCall(domName, expr)));
1486
1686
  }
1687
+ continue;
1688
+ }
1689
+
1690
+ // A literal attribute that follows a spread. Everything before the first
1691
+ // spread is in the template; this one is not, so re-apply it here to
1692
+ // restore the order the author wrote. A valueless attribute is `true` in
1693
+ // JSX, which is what the h() spelling passes.
1694
+ if (seenSpread) {
1695
+ statements.push(
1696
+ t.expressionStatement(
1697
+ buildSetPropCall(normalizeAttrName(attrName), getAttributeValue(attr.value))
1698
+ )
1699
+ );
1487
1700
  }
1488
1701
  }
1489
1702
  }
@@ -1646,8 +1859,24 @@ export default function whatBabelPlugin({ types: t }) {
1646
1859
  }
1647
1860
 
1648
1861
  if (t.isJSXFragment(child)) {
1649
- entries.push({ type: 'fragment', child, childIndex });
1650
- childIndex++;
1862
+ // Flattened: every slot the fragment contributes gets its own marker and
1863
+ // its own entry, so it is handled exactly like a child written directly
1864
+ // under this element. That is what keeps document order stable across
1865
+ // re-runs, and it also means fragment children now get the treatments
1866
+ // the old single-anchor branch reimplemented without: keyed .map()
1867
+ // lowering, branch memoization and per-element dynamic attributes.
1868
+ for (const slot of fragmentSlots(child)) {
1869
+ if (slot.kind === 'text') {
1870
+ entries.push({ type: 'fragmentText', text: slot.text, childIndex });
1871
+ } else if (slot.kind === 'expression') {
1872
+ entries.push({ type: 'expression', child: slot.child, childIndex });
1873
+ } else {
1874
+ // The template holds only a marker here, so the element is built at
1875
+ // runtime and inserted before it, which is the 'component' slot shape.
1876
+ entries.push({ type: 'component', child: slot.child, childIndex });
1877
+ }
1878
+ childIndex++;
1879
+ }
1651
1880
  }
1652
1881
  }
1653
1882
 
@@ -1655,7 +1884,7 @@ export default function whatBabelPlugin({ types: t }) {
1655
1884
  // When there are multiple entries needing DOM refs and at least one _$insert(),
1656
1885
  // capture all markers upfront to avoid index shifting after DOM mutations.
1657
1886
  const entriesNeedingRef = entries.filter(e =>
1658
- e.type === 'expression' || e.type === 'component' || e.type === 'fragment' ||
1887
+ e.type === 'expression' || e.type === 'component' || e.type === 'fragmentText' ||
1659
1888
  (e.type === 'static' && e.hasAnythingDynamic)
1660
1889
  );
1661
1890
  // Pre-capture whenever 2+ children need a DOM ref. Beyond preventing index
@@ -1837,96 +2066,18 @@ export default function whatBabelPlugin({ types: t }) {
1837
2066
  continue;
1838
2067
  }
1839
2068
 
1840
- if (entry.type === 'fragment') {
1841
- // A fragment child used to handle ONLY expression children, and to
1842
- // insert them with no anchor. Three things were wrong with that, all
1843
- // silent: text children were dropped, element children were dropped,
1844
- // and the expressions that did survive were appended to the end of the
1845
- // parent rather than placed where the fragment sits. So
1846
- // `<span><>a<b>c</b></>{x}</span>` rendered `<span>x</span>`.
1847
- //
1848
- // Every child now inserts before the fragment's own marker. Repeated
1849
- // insertBefore against one anchor appends in call order, so the
1850
- // fragment's children keep their order and the fragment keeps its place
1851
- // among its siblings.
1852
- //
1853
- // The marker is captured into a variable first. Each insert mutates the
1854
- // parent, so an inline `el.firstChild.nextSibling…` walk would resolve
1855
- // against a tree that the previous insert already shifted.
1856
- const idx = /** @type {number} */ (entry.childIndex);
1857
- let anchor = getMarker(idx);
1858
- if (!t.isIdentifier(anchor)) {
1859
- const anchorVar = state.nextVarId();
1860
- statements.push(
1861
- t.variableDeclaration('const', [
1862
- t.variableDeclarator(t.identifier(anchorVar), anchor)
2069
+ if (entry.type === 'fragmentText') {
2070
+ state.needsInsert = true;
2071
+ statements.push(
2072
+ t.expressionStatement(
2073
+ t.callExpression(t.identifier('_$insert'), [
2074
+ t.identifier(elId),
2075
+ t.stringLiteral(entry.text),
2076
+ getMarker(entry.childIndex),
1863
2077
  ])
1864
- );
1865
- anchor = t.identifier(anchorVar);
1866
- }
1867
- emitFragmentInserts(statements, elId, entry.child, anchor, state);
1868
- }
1869
- }
1870
-
1871
- // Insert one fragment's children into `elId`, each before `anchor`.
1872
- // Recursive, because `<><>a</>b</>` is a fragment whose child is a fragment
1873
- // and both belong at the same position in the parent.
1874
- function emitFragmentInserts(statements, elId, fragmentNode, anchor, state) {
1875
- for (const fChild of fragmentNode.children) {
1876
- if (t.isJSXText(fChild)) {
1877
- const text = normalizeJsxText(fChild.value);
1878
- if (!text) continue;
1879
- state.needsInsert = true;
1880
- statements.push(
1881
- t.expressionStatement(
1882
- t.callExpression(t.identifier('_$insert'), [
1883
- t.identifier(elId),
1884
- t.stringLiteral(text),
1885
- anchor,
1886
- ])
1887
- )
1888
- );
1889
- continue;
1890
- }
1891
-
1892
- if (t.isJSXFragment(fChild)) {
1893
- emitFragmentInserts(statements, elId, fChild, anchor, state);
1894
- continue;
1895
- }
1896
-
1897
- if (t.isJSXElement(fChild)) {
1898
- state.needsInsert = true;
1899
- statements.push(
1900
- t.expressionStatement(
1901
- t.callExpression(t.identifier('_$insert'), [
1902
- t.identifier(elId),
1903
- transformElementFineGrained({ node: fChild }, state),
1904
- anchor,
1905
- ])
1906
- )
1907
- );
1908
- continue;
1909
- }
1910
-
1911
- if (t.isJSXExpressionContainer(fChild) && !t.isJSXEmptyExpression(fChild.expression)) {
1912
- state.needsInsert = true;
1913
- let expr = fChild.expression;
1914
- // Unchanged from before this fix: a potentially reactive expression
1915
- // inside a fragment is wrapped in a thunk so insert() re-runs it.
1916
- if (isPotentiallyReactive(expr, state.signalNames, state.importedIdentifiers)) {
1917
- expr = memoizeBranchCondition(expr, statements, state); // (C1)
1918
- expr = t.arrowFunctionExpression([], expr);
1919
- }
1920
- statements.push(
1921
- t.expressionStatement(
1922
- t.callExpression(t.identifier('_$insert'), [
1923
- t.identifier(elId),
1924
- expr,
1925
- anchor,
1926
- ])
1927
- )
1928
- );
1929
- }
2078
+ )
2079
+ );
2080
+ continue;
1930
2081
  }
1931
2082
  }
1932
2083
  }
@@ -1953,6 +2104,7 @@ export default function whatBabelPlugin({ types: t }) {
1953
2104
  const children = node.children;
1954
2105
 
1955
2106
  // Check for client: directive (islands)
2107
+ /** @type {{ type: string, value?: any } | null} */
1956
2108
  let clientDirective = null;
1957
2109
  const filteredAttrs = [];
1958
2110
 
@@ -2002,7 +2154,17 @@ export default function whatBabelPlugin({ types: t }) {
2002
2154
  } else if (t.isJSXExpressionContainer(child)) {
2003
2155
  if (!t.isJSXEmptyExpression(child.expression)) {
2004
2156
  deferChildren = true;
2005
- transformedChildren.push(child.expression);
2157
+ // The same lowering an element child and a fragment child get.
2158
+ // Without it `<Box>{count()}</Box>` was evaluated once and never
2159
+ // again, while `<div>{count()}</div>` right beside it stayed live —
2160
+ // the auto-thunk docs/GOTCHAS.md section 2 promises for "any call
2161
+ // with no arguments" reached every JSX position EXCEPT this one.
2162
+ //
2163
+ // Nothing about it is component-specific: a component's children
2164
+ // are built into an array with no host element to _$insert into,
2165
+ // which is exactly the situation lowerFragmentExprChild exists for,
2166
+ // and it is where `.map()` picks up keyed reconciliation too.
2167
+ transformedChildren.push(lowerFragmentExprChild(child.expression, state));
2006
2168
  }
2007
2169
  } else if (t.isJSXElement(child)) {
2008
2170
  // <For>/<Show>/<Switch> lower to an inserter or a thunk (already lazy).
@@ -2034,39 +2196,71 @@ export default function whatBabelPlugin({ types: t }) {
2034
2196
  state.needsCreateComponent = true;
2035
2197
  state.needsIsland = true;
2036
2198
 
2037
- const islandProps = [
2199
+ // The directive's own machinery, kept apart from user data because it is
2200
+ // always applied LAST: `component` comes from the tag and `mode` from the
2201
+ // directive, so nothing a caller spreads in can reach either one.
2202
+ const directiveProps = [
2038
2203
  t.objectProperty(t.identifier('component'), componentRef),
2039
2204
  t.objectProperty(t.identifier('mode'), t.stringLiteral(clientDirective.type)),
2040
2205
  ];
2041
2206
 
2042
2207
  if (clientDirective.value) {
2043
- islandProps.push(
2208
+ directiveProps.push(
2044
2209
  t.objectProperty(t.identifier('mediaQuery'), t.stringLiteral(clientDirective.value))
2045
2210
  );
2046
2211
  }
2047
2212
 
2048
- let islandSpread = null;
2213
+ // User props in source order — same rule, and the same reasons, as the
2214
+ // regular component path below. An island IS a component, so
2215
+ // `<Chart client:load {...a} {...b} />` has to mean `{ ...a, ...b }` for
2216
+ // exactly the reason `<Chart {...a} {...b} />` does.
2217
+ const islandParts = [];
2218
+ let islandUserProps = [];
2219
+ const flushIslandUserProps = () => {
2220
+ if (islandUserProps.length > 0) {
2221
+ islandParts.push(t.objectExpression(islandUserProps));
2222
+ islandUserProps = [];
2223
+ }
2224
+ };
2225
+
2049
2226
  for (const attr of filteredAttrs) {
2050
2227
  if (t.isJSXSpreadAttribute(attr)) {
2051
2228
  // A spread used to be dropped on the floor here, so
2052
2229
  // `<Chart client:visible {...config} />` lost every prop in `config`.
2053
- islandSpread = attr.argument;
2230
+ // Then only the LAST spread was kept, which lost every key an earlier
2231
+ // spread carried and the last one did not.
2232
+ flushIslandUserProps();
2233
+ islandParts.push(attr.argument);
2054
2234
  continue;
2055
2235
  }
2056
2236
  const attrName = getAttrName(attr);
2057
2237
  if (attrName === 'key') continue;
2058
2238
  const value = getAttributeValue(attr.value);
2059
- islandProps.push(t.objectProperty(t.identifier(attrName), value));
2239
+ // `data-x` and `aria-label` are not identifiers. Emitting one as a bare
2240
+ // key produced `{data-x: "1"}`, which is a SYNTAX ERROR: babel does not
2241
+ // validate what its builders are handed, so the compiler exited 0 and
2242
+ // wrote a file that no parser would accept. `<Chart client:load
2243
+ // aria-label="..." />` is an ordinary thing to write, and it took the
2244
+ // whole build down with an error pointing at generated output.
2245
+ //
2246
+ // The regular component branch below has always tested the name first;
2247
+ // this branch is the copy that did not.
2248
+ islandUserProps.push(t.objectProperty(propKey(attrName), value));
2060
2249
  }
2061
2250
 
2062
- // Spread first, explicit attributes second, so a written-out prop wins over
2063
- // the same key coming from the spread (JSX evaluation order).
2064
- const islandPropsExpr = islandSpread
2065
- ? t.callExpression(
2066
- t.memberExpression(t.identifier('Object'), t.identifier('assign')),
2067
- [t.objectExpression([]), islandSpread, t.objectExpression(islandProps)]
2068
- )
2069
- : t.objectExpression(islandProps);
2251
+ let islandPropsExpr;
2252
+ if (islandParts.length === 0) {
2253
+ // No spread anywhere: one object literal, directive keys written last so
2254
+ // they still win. This is the shape nearly every island has, and keeping
2255
+ // it a literal keeps the emitted output byte-identical to before.
2256
+ islandPropsExpr = t.objectExpression([...islandUserProps, ...directiveProps]);
2257
+ } else {
2258
+ flushIslandUserProps();
2259
+ islandPropsExpr = t.callExpression(
2260
+ t.memberExpression(t.identifier('Object'), t.identifier('assign')),
2261
+ [t.objectExpression([]), ...islandParts, t.objectExpression(directiveProps)]
2262
+ );
2263
+ }
2070
2264
 
2071
2265
  return t.callExpression(
2072
2266
  t.identifier('_$createComponent'),
@@ -2074,17 +2268,51 @@ export default function whatBabelPlugin({ types: t }) {
2074
2268
  );
2075
2269
  }
2076
2270
 
2077
- // Regular component — use _$createComponent to instantiate, component runs once
2078
- state.needsCreateComponent = true;
2079
-
2080
- const props = [];
2081
- let hasSpread = false;
2082
- let spreadExpr = null;
2271
+ // Regular component — use _$createComponent to instantiate, component runs once.
2272
+ // Unless this element IS the tree handed to hydrate(), in which case it has
2273
+ // to arrive unbuilt: see isHydrateRootArgument.
2274
+ //
2275
+ // The root, and only the root. A host element lowers to a template clone, so
2276
+ // "unbuilt" is not something the children of an arbitrary tree can inherit —
2277
+ // <App><p>hi</p></App> would still build the <p>, and pushing the choice down
2278
+ // through component children only would produce a tree that hydrates in
2279
+ // patches. The root is the one position where the answer is unambiguous and
2280
+ // it is where the documented entry point lives.
2281
+ const isHydrateRoot = isHydrateRootArgument(path);
2282
+ if (isHydrateRoot) state.needsComponentVNode = true;
2283
+ else state.needsCreateComponent = true;
2284
+
2285
+ // Props resolve in SOURCE order. `<Box a {...s} b />` is one spelling of
2286
+ // `{ a, ...s, b }`, which is what h(), the JSX runtime, every other JSX
2287
+ // toolchain, and (since #65) this compiler's own ELEMENT path all produce.
2288
+ // `parts` is that argument list: runs of explicit attributes, separated by
2289
+ // the spreads written between them.
2290
+ //
2291
+ // Keeping a single `spreadExpr` and appending every explicit prop last, the
2292
+ // way this used to, got both halves wrong. `<Box {...a} {...b} />` kept only
2293
+ // `b`, so every key `a` carried and `b` did not vanished completely — data
2294
+ // loss, not merely precedence — and an attribute written BEFORE a spread
2295
+ // beat it, which no other JSX implementation does.
2296
+ //
2297
+ // The merge is a shallow copy, deliberately: a prop is reactive here when
2298
+ // its VALUE is an accessor, and Object.assign copies that function through
2299
+ // untouched. A lone non-literal spread is copied too, the same
2300
+ // Object.assign path a multi-prop merge already used, so two
2301
+ // `<Box {...reused}>` sites do not share one identity. createComponent
2302
+ // (dom.js) copies again before the component sees the props.
2303
+ const parts = [];
2304
+ let props = [];
2305
+ const flushProps = () => {
2306
+ if (props.length > 0) {
2307
+ parts.push(t.objectExpression(props));
2308
+ props = [];
2309
+ }
2310
+ };
2083
2311
 
2084
2312
  for (const attr of filteredAttrs) {
2085
2313
  if (t.isJSXSpreadAttribute(attr)) {
2086
- hasSpread = true;
2087
- spreadExpr = attr.argument;
2314
+ flushProps();
2315
+ parts.push(attr.argument);
2088
2316
  continue;
2089
2317
  }
2090
2318
 
@@ -2151,35 +2379,32 @@ export default function whatBabelPlugin({ types: t }) {
2151
2379
 
2152
2380
  const value = getAttributeValue(attr.value);
2153
2381
 
2154
- props.push(
2155
- t.objectProperty(
2156
- /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(attrName)
2157
- ? t.identifier(attrName)
2158
- : t.stringLiteral(attrName),
2159
- value
2160
- )
2161
- );
2382
+ props.push(t.objectProperty(propKey(attrName), value));
2162
2383
  }
2163
2384
 
2385
+ flushProps();
2386
+
2164
2387
  let propsExpr;
2165
- if (hasSpread) {
2166
- if (props.length > 0) {
2167
- propsExpr = t.callExpression(
2168
- t.memberExpression(t.identifier('Object'), t.identifier('assign')),
2169
- [t.objectExpression([]), spreadExpr, t.objectExpression(props)]
2170
- );
2171
- } else {
2172
- propsExpr = spreadExpr;
2173
- }
2174
- } else if (props.length > 0) {
2175
- propsExpr = t.objectExpression(props);
2176
- } else {
2388
+ if (parts.length === 0) {
2177
2389
  propsExpr = t.nullLiteral();
2390
+ } else if (parts.length === 1 && t.isObjectExpression(parts[0])) {
2391
+ // A lone object literal (no spread, or an inline `{...}`) is already a
2392
+ // fresh props object. A lone identifier/member/call spread is the
2393
+ // CALLER'S object and must go through the copy below.
2394
+ propsExpr = parts[0];
2395
+ } else {
2396
+ propsExpr = t.callExpression(
2397
+ t.memberExpression(t.identifier('Object'), t.identifier('assign')),
2398
+ [t.objectExpression([]), ...parts]
2399
+ );
2178
2400
  }
2179
2401
 
2180
2402
  const childrenArg = transformComponentChildren();
2181
2403
 
2182
- return t.callExpression(t.identifier('_$createComponent'), [componentRef, propsExpr, childrenArg]);
2404
+ return t.callExpression(
2405
+ t.identifier(isHydrateRoot ? '_$componentVNode' : '_$createComponent'),
2406
+ [componentRef, propsExpr, childrenArg]
2407
+ );
2183
2408
  }
2184
2409
 
2185
2410
  function transformForFineGrained(path, state) {
@@ -3066,6 +3291,7 @@ export default function whatBabelPlugin({ types: t }) {
3066
3291
  state.needsSetChecked = false;
3067
3292
  state.needsH = false;
3068
3293
  state.needsCreateComponent = false;
3294
+ state.needsComponentVNode = false;
3069
3295
  state.needsFragment = false;
3070
3296
  state.needsIsland = false;
3071
3297
  state.needsDelegation = false;
@@ -3112,11 +3338,7 @@ export default function whatBabelPlugin({ types: t }) {
3112
3338
  for (const node of path.node.body) {
3113
3339
  if (t.isImportDeclaration(node)) {
3114
3340
  const source = node.source.value;
3115
- const isWhatSource =
3116
- source === 'what-framework' ||
3117
- source.startsWith('what-framework/') ||
3118
- source === 'what-core' ||
3119
- source.startsWith('what-core/');
3341
+ const isWhatSource = isWhatModuleSource(source);
3120
3342
  const isReactiveSource =
3121
3343
  isWhatSource ||
3122
3344
  source.startsWith('./') ||
@@ -3285,6 +3507,11 @@ export default function whatBabelPlugin({ types: t }) {
3285
3507
  t.importSpecifier(t.identifier('_$createComponent'), t.identifier('_$createComponent'))
3286
3508
  );
3287
3509
  }
3510
+ if (state.needsComponentVNode) {
3511
+ fgSpecifiers.push(
3512
+ t.importSpecifier(t.identifier('_$componentVNode'), t.identifier('_$componentVNode'))
3513
+ );
3514
+ }
3288
3515
  if (state.needsDelegation) {
3289
3516
  fgSpecifiers.push(
3290
3517
  t.importSpecifier(t.identifier('_$delegateEvents'), t.identifier('delegateEvents'))
@@ -3310,6 +3537,7 @@ export default function whatBabelPlugin({ types: t }) {
3310
3537
  }
3311
3538
 
3312
3539
  if (fgSpecifiers.length > 0) {
3540
+ /** @type {any} */
3313
3541
  let existingRenderImport = null;
3314
3542
  for (const node of path.node.body) {
3315
3543
  if (t.isImportDeclaration(node) && (
@@ -3400,6 +3628,7 @@ export default function whatBabelPlugin({ types: t }) {
3400
3628
  }
3401
3629
 
3402
3630
  function addCoreImports(path, t, coreSpecifiers) {
3631
+ /** @type {any} */
3403
3632
  let existingImport = null;
3404
3633
  for (const node of path.node.body) {
3405
3634
  if (t.isImportDeclaration(node) && (