what-compiler 0.12.2 → 0.12.4

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.
@@ -178,6 +178,43 @@ export default function whatBabelPlugin({ types: t }) {
178
178
  return /^[A-Z]/.test(name);
179
179
  }
180
180
 
181
+ // Build the Error for a JSX shape the compiler refuses to lower.
182
+ //
183
+ // Every fine-grained transform is reachable two ways: from the JSXElement
184
+ // visitor, where `path` is a real NodePath, and recursively from a parent
185
+ // transform, which passes a SYNTHETIC `{ node: child }` (see the
186
+ // transformElementFineGrained / transformFragmentFineGrained calls further
187
+ // down). Only the visitor's path carries buildCodeFrameError, so calling it
188
+ // unguarded threw "path.buildCodeFrameError is not a function" — an internal
189
+ // crash in place of the diagnostic the author was meant to act on. That hit
190
+ // the ordinary shapes, not the exotic ones: `<div><Show>…</Show></div>` is
191
+ // already synthetic, so only a control-flow tag that was the ENTIRE return
192
+ // value ever reported properly.
193
+ //
194
+ // Guarding the call is not enough either. `throw new Error(message)` reaches
195
+ // the developer, but with no file and no line, which for a build error in a
196
+ // 300-file app is barely better than the crash. state.file is Babel's File
197
+ // for the module being compiled and its buildCodeFrameError(node, msg) pins
198
+ // the frame from the node's own loc, so a synthetic path still gets the same
199
+ // pointed-at source the visitor path does.
200
+ function compileError(path, state, message) {
201
+ if (typeof path?.buildCodeFrameError === 'function') {
202
+ return path.buildCodeFrameError(message);
203
+ }
204
+ const node = path?.node;
205
+ if (node && typeof state?.file?.buildCodeFrameError === 'function') {
206
+ return state.file.buildCodeFrameError(node, message);
207
+ }
208
+ // No NodePath and no File (a direct unit-test call, or a node the parser
209
+ // never produced). Name the file and line by hand so the message still
210
+ // says where to look.
211
+ const loc = node?.loc?.start;
212
+ const filename = state?.filename || state?.file?.opts?.filename || '<unknown>';
213
+ return new Error(
214
+ `[what-compiler] ${message} (${loc ? `${filename}:${loc.line}:${loc.column + 1}` : filename})`
215
+ );
216
+ }
217
+
181
218
  // Dotted tags (<Ctx.Provider>, <Foo.Bar.Baz>) are always components.
182
219
  function isComponentElement(el) {
183
220
  const name = el.openingElement ? el.openingElement.name : el.name;
@@ -391,6 +428,22 @@ export default function whatBabelPlugin({ types: t }) {
391
428
  // defined in each lexical scope (function/block).
392
429
  function collectSignalNamesFromScope(path) {
393
430
  const signalNames = new Set();
431
+ // Names that came from a DESTRUCTURED PROP rather than a signal() call.
432
+ //
433
+ // They belong in signalNames because they might hold an accessor and so
434
+ // must still be wrapped in an effect. They must NOT be auto-invoked: a prop
435
+ // is whatever the parent passed, and `_$createComponent` passes plain
436
+ // values, so `<span data-label={label}>` inside `({ label })` compiled to
437
+ // `setAttr(el, 'data-label', label())` and threw "label is not a function"
438
+ // on any ordinary string prop, blanking the whole component subtree.
439
+ //
440
+ // The same identifier as a CHILD compiled to `() => label`, uncalled, so
441
+ // the two positions disagreed inside a single element. The runtime setters
442
+ // (setAttr, setClass, setValue, ...) already resolve a function value
443
+ // reactively, so passing the identifier through uncalled is correct for
444
+ // both a plain value and an accessor.
445
+ const propNames = new Set();
446
+ signalNames.fromDestructuredProps = propNames;
394
447
 
395
448
  // Helper: extract signal names from a VariableDeclarator node
396
449
  function extractFromDeclarator(decl) {
@@ -428,8 +481,10 @@ export default function whatBabelPlugin({ types: t }) {
428
481
  for (const prop of param.properties) {
429
482
  if (t.isObjectProperty(prop) && t.isIdentifier(prop.value)) {
430
483
  signalNames.add(prop.value.name);
484
+ propNames.add(prop.value.name);
431
485
  } else if (t.isRestElement(prop) && t.isIdentifier(prop.argument)) {
432
486
  signalNames.add(prop.argument.name);
487
+ propNames.add(prop.argument.name);
433
488
  }
434
489
  }
435
490
  }
@@ -602,6 +657,111 @@ export default function whatBabelPlugin({ types: t }) {
602
657
  return false;
603
658
  }
604
659
 
660
+ // Names bound by a parameter pattern. Destructuring counts, so
661
+ // `({ id }, i) => <li key={id}>` binds `id` and a key of `id` resolves fine.
662
+ function collectPatternNames(node, out) {
663
+ if (!node || typeof node !== 'object') return out;
664
+ switch (node.type) {
665
+ case 'Identifier': out.add(node.name); break;
666
+ case 'AssignmentPattern': collectPatternNames(node.left, out); break;
667
+ case 'RestElement': collectPatternNames(node.argument, out); break;
668
+ case 'ArrayPattern':
669
+ for (const el of node.elements) collectPatternNames(el, out);
670
+ break;
671
+ case 'ObjectPattern':
672
+ for (const p of node.properties) {
673
+ collectPatternNames(p.type === 'RestElement' ? p.argument : p.value, out);
674
+ }
675
+ break;
676
+ default: break;
677
+ }
678
+ return out;
679
+ }
680
+
681
+ // The key function is extracted OUT of the map callback and rebuilt as
682
+ // `(item) => keyExpr`, taking only the first parameter. So any name the key
683
+ // expression gets from somewhere else inside the callback becomes a free
684
+ // variable in the emitted code. This collects exactly those names: the
685
+ // parameters after the first, and everything declared in the callback body
686
+ // that is in scope at the return.
687
+ //
688
+ // Nested function bodies are deliberately not descended into. Their bindings
689
+ // cannot reach the return expression, so counting them would reject keys that
690
+ // are perfectly fine. A nested function's own name is hoisted, so that one
691
+ // is collected.
692
+ function namesInvisibleToKeyFn(mapFn) {
693
+ const out = new Set();
694
+ for (let i = 1; i < mapFn.params.length; i++) collectPatternNames(mapFn.params[i], out);
695
+
696
+ const visit = (node) => {
697
+ if (!node || typeof node !== 'object') return;
698
+ if (Array.isArray(node)) { node.forEach(visit); return; }
699
+ switch (node.type) {
700
+ case 'VariableDeclaration':
701
+ for (const d of node.declarations) collectPatternNames(d.id, out);
702
+ return;
703
+ case 'FunctionDeclaration':
704
+ case 'ClassDeclaration':
705
+ if (node.id) out.add(node.id.name);
706
+ return; // body is its own scope
707
+ case 'FunctionExpression':
708
+ case 'ArrowFunctionExpression':
709
+ case 'ClassExpression':
710
+ return; // own scope
711
+ case 'CatchClause':
712
+ collectPatternNames(node.param, out);
713
+ visit(node.body);
714
+ return;
715
+ case 'ForStatement':
716
+ case 'ForInStatement':
717
+ case 'ForOfStatement':
718
+ visit(node.init); visit(node.left); visit(node.body);
719
+ return;
720
+ default: break;
721
+ }
722
+ for (const k of Object.keys(node)) {
723
+ if (k === 'loc' || k === 'start' || k === 'end' || k === 'type' ||
724
+ k === 'leadingComments' || k === 'trailingComments' || k === 'innerComments') continue;
725
+ const v = node[k];
726
+ if (v && typeof v === 'object') visit(v);
727
+ }
728
+ };
729
+
730
+ if (t.isBlockStatement(mapFn.body)) visit(mapFn.body.body);
731
+
732
+ // The first parameter IS passed to the key function, so anything it binds
733
+ // stays visible even if a nested scope happens to reuse the name.
734
+ for (const name of collectPatternNames(mapFn.params[0], new Set())) out.delete(name);
735
+ return out;
736
+ }
737
+
738
+ // Does `node` reference any of `names` as a value? Property positions are
739
+ // skipped so `item.i` does not look like a use of `i`, but the check is
740
+ // otherwise deliberately generous: a false positive costs keyed
741
+ // reconciliation, a false negative ships a ReferenceError.
742
+ function referencesAny(node, names) {
743
+ if (names.size === 0) return null;
744
+ let found = null;
745
+ const visit = (n) => {
746
+ if (found || !n || typeof n !== 'object') return;
747
+ if (Array.isArray(n)) { n.forEach(visit); return; }
748
+ if (n.type === 'Identifier') {
749
+ if (names.has(n.name)) found = n.name;
750
+ return;
751
+ }
752
+ for (const k of Object.keys(n)) {
753
+ if (k === 'loc' || k === 'start' || k === 'end' || k === 'type' ||
754
+ k === 'leadingComments' || k === 'trailingComments' || k === 'innerComments') continue;
755
+ if (n.type === 'MemberExpression' && k === 'property' && !n.computed) continue;
756
+ if ((n.type === 'ObjectProperty' || n.type === 'ObjectMethod') && k === 'key' && !n.computed) continue;
757
+ const v = n[k];
758
+ if (v && typeof v === 'object') visit(v);
759
+ }
760
+ };
761
+ visit(node);
762
+ return found;
763
+ }
764
+
605
765
  // --- Auto-lower .map() to mapArray ---
606
766
  // Detects: source().map((item) => <Comp key={expr} .../>)
607
767
  // or wrapped in an arrow: () => source().map(...)
@@ -689,14 +849,23 @@ export default function whatBabelPlugin({ types: t }) {
689
849
  // JSX returned without a key — bail out, but warn at compile time so
690
850
  // users notice they're missing keyed reconciliation. Only warn in dev
691
851
  // (production builds are noiseless).
852
+ //
853
+ // The message names ERR_MISSING_KEY because that is the code the docs and
854
+ // the MCP `what_fix` tool already publish for this exact mistake. Without
855
+ // the code in the text there was nothing anywhere in the toolchain that
856
+ // ever emitted it, so an agent reading the warning had no way to reach
857
+ // the diagnosis and the worked example filed under that name. This is a
858
+ // BUILD-time report, not a runtime one: whether a list has keys is
859
+ // settled by the source, and the compiler is the only place that sees it.
692
860
  if (process.env.NODE_ENV !== 'production') {
693
861
  const loc = returnExpr.loc;
694
862
  const fileName = state.filename || state.file?.opts?.filename || '<unknown>';
695
863
  const lineInfo = loc ? `:${loc.start.line}:${loc.start.column}` : '';
696
864
  console.warn(
697
- `[what-compiler] .map() returning JSX without a \`key\` prop at ${fileName}${lineInfo}. ` +
865
+ `[what-compiler] ERR_MISSING_KEY: .map() returning JSX without a \`key\` prop at ${fileName}${lineInfo}. ` +
698
866
  `Without a key, the list cannot use keyed reconciliation — items are re-created on every update. ` +
699
- `Add key={...} to enable efficient updates.`
867
+ `Add key={...} to enable efficient updates. ` +
868
+ `Run what_fix({ error: 'ERR_MISSING_KEY' }) for the worked example.`
700
869
  );
701
870
  }
702
871
  return null;
@@ -706,6 +875,60 @@ export default function whatBabelPlugin({ types: t }) {
706
875
  const keyValue = getAttributeValue(keyAttr.value);
707
876
  if (!keyValue) return null;
708
877
 
878
+ // The key function is rebuilt as `(item) => keyExpr` and hoisted out of the
879
+ // callback, so it only sees the first parameter. A key built from the index
880
+ // (`key={i}`, the pattern the tutorial itself taught) compiled to
881
+ // `key: t => i` with `i` free, and the failure was about as bad as it gets:
882
+ // the list rendered correctly the first time, then the reconciler threw
883
+ // `ReferenceError: i is not defined` inside its effect, the effect error
884
+ // handler swallowed it to a single console.error, and the list stayed
885
+ // frozen on its first render forever. Nothing visibly crashed. A key built
886
+ // from a variable declared in the callback body failed the same way.
887
+ //
888
+ // Falling back is also the semantically right answer, not just the safe
889
+ // one. A key derived from the index IS the position, so it carries no
890
+ // identity across an update: an item that moves gets a different key and
891
+ // reads as a different item. That is precisely what the unkeyed path
892
+ // already does, correctly and more cheaply, so hand it that.
893
+ const invisible = namesInvisibleToKeyFn(mapFn);
894
+ const unreachable = referencesAny(keyValue, invisible);
895
+ if (unreachable) {
896
+ // A BARE index key (`key={i}`, exactly the second parameter and nothing
897
+ // else) is deliberate and now behaves correctly, so it gets no warning.
898
+ // The author wrote "position is identity", positional reconciliation is
899
+ // precisely that, and there is no edit that would improve the output. The
900
+ // framework's own tutorial keys a fixed nine-square board this way, where
901
+ // it is not merely acceptable but the right answer, and a build-time
902
+ // warning on step two of a beginner tutorial reads as something being
903
+ // broken when nothing is.
904
+ //
905
+ // Everything else still warns, because everything else looks stable and is
906
+ // not: `key={`${t.type}-${i}`}` reads like a composite identity but changes
907
+ // the moment a row moves, and a key built from a variable declared in the
908
+ // callback body would have been a ReferenceError before this fallback
909
+ // existed. Those are worth interrupting someone over. A bare index is not.
910
+ const isBareIndex = t.isIdentifier(keyValue)
911
+ && mapFn.params[1]
912
+ && t.isIdentifier(mapFn.params[1], { name: keyValue.name });
913
+
914
+ if (!isBareIndex && process.env.NODE_ENV !== 'production') {
915
+ const loc = returnExpr.loc;
916
+ const fileName = state.filename || state.file?.opts?.filename || '<unknown>';
917
+ const lineInfo = loc ? `:${loc.start.line}:${loc.start.column}` : '';
918
+ const usesIndex = mapFn.params[1] && t.isIdentifier(mapFn.params[1], { name: unreachable });
919
+ console.warn(
920
+ `[what-compiler] key={...} at ${fileName}${lineInfo} reads \`${unreachable}\`, which is not available ` +
921
+ `to the key function (it only receives the item). ` +
922
+ (usesIndex
923
+ ? `A key built from the index is the item's position, not its identity, so it cannot survive a reorder. `
924
+ : `\`${unreachable}\` is declared inside the map callback. `) +
925
+ `Falling back to positional reconciliation. Key by something stable on the item (key={item.id}) ` +
926
+ `to get keyed reconciliation.`
927
+ );
928
+ }
929
+ return null;
930
+ }
931
+
709
932
  // Remove the key prop from the JSX element (mapArray handles keying, not the DOM)
710
933
  returnExpr.openingElement.attributes = attrs.filter(a => a !== keyAttr);
711
934
 
@@ -1228,7 +1451,14 @@ export default function whatBabelPlugin({ types: t }) {
1228
1451
  if (isPotentiallyReactive(expr, state.signalNames, state.importedIdentifiers)) {
1229
1452
  state.needsEffect = true;
1230
1453
  // Auto-invoke bare signal/imported identifiers: value={name} -> name()
1454
+ //
1455
+ // Never a destructured prop: it holds whatever the parent passed,
1456
+ // which for `_$createComponent` is a plain value, and calling it
1457
+ // threw. The runtime setters resolve a function value themselves, so
1458
+ // an uncalled identifier is correct for a prop either way.
1459
+ const fromProps = state.signalNames && state.signalNames.fromDestructuredProps;
1231
1460
  const valueExpr = t.isIdentifier(expr) &&
1461
+ !(fromProps && fromProps.has(expr.name)) &&
1232
1462
  (isSignalIdentifier(expr.name, state.signalNames) ||
1233
1463
  (state.importedIdentifiers && state.importedIdentifiers.has(expr.name)))
1234
1464
  ? t.callExpression(expr, [])
@@ -1300,6 +1530,31 @@ export default function whatBabelPlugin({ types: t }) {
1300
1530
  return false;
1301
1531
  }
1302
1532
 
1533
+ // Does `path` sit in a slot its parent only evaluates SOMETIMES?
1534
+ //
1535
+ // Used to decide whether a JSX root's setup statements may be hoisted out to
1536
+ // the enclosing statement. Anything in one of these slots must not be: the
1537
+ // source deliberately said "only build this when the guard opens", and the
1538
+ // hoist turns that into "always build this".
1539
+ //
1540
+ // Only the slots that are genuinely skipped count. A logical expression's
1541
+ // LEFT operand and a ternary's TEST always run, so JSX there is not guarded
1542
+ // and keeps the cheaper hoist.
1543
+ function isConditionallyEvaluated(path) {
1544
+ const parent = path.parent;
1545
+ if (!parent) return false;
1546
+ if (t.isLogicalExpression(parent)) return path.key === 'right';
1547
+ if (t.isConditionalExpression(parent)) {
1548
+ return path.key === 'consequent' || path.key === 'alternate';
1549
+ }
1550
+ // `maybe?.render(<jsx/>)` skips its whole argument list when the chain
1551
+ // short-circuits, so an argument is guarded; the callee/object is not.
1552
+ if (t.isOptionalCallExpression(parent) || t.isOptionalMemberExpression(parent)) {
1553
+ return path.key !== 'callee' && path.key !== 'object';
1554
+ }
1555
+ return false;
1556
+ }
1557
+
1303
1558
  // If `expr` is a conditional (ternary / && / ||) with a reactive test and a
1304
1559
  // DOM-producing branch, hoist the test into `const _c$N = _$memo(...)` (pushed
1305
1560
  // onto `statements`) and return the expression rewritten to read the memo.
@@ -1887,11 +2142,13 @@ export default function whatBabelPlugin({ types: t }) {
1887
2142
 
1888
2143
  let eachExpr = null;
1889
2144
  let keyExpr = null;
2145
+ let fallbackExpr = null;
1890
2146
  for (const attr of attributes) {
1891
2147
  if (t.isJSXAttribute(attr)) {
1892
2148
  const name = getAttrName(attr);
1893
2149
  if (name === 'each') eachExpr = getAttributeValue(attr.value);
1894
2150
  else if (name === 'key') keyExpr = getAttributeValue(attr.value);
2151
+ else if (name === 'fallback') fallbackExpr = getAttributeValue(attr.value);
1895
2152
  }
1896
2153
  }
1897
2154
 
@@ -1916,13 +2173,184 @@ export default function whatBabelPlugin({ types: t }) {
1916
2173
  }
1917
2174
 
1918
2175
  state.needsMapArray = true;
1919
- const args = [eachExpr, renderFn];
1920
- if (keyExpr) {
1921
- args.push(t.objectExpression([
1922
- t.objectProperty(t.identifier('key'), keyExpr)
1923
- ]));
2176
+
2177
+ // `_$mapArray(source, renderFn[, { key }])` — built from whichever source
2178
+ // expression the caller wants, because the fallback lowering below has to
2179
+ // read the source twice and therefore binds it to a const first.
2180
+ const buildMapArrayCall = (sourceExpr) => {
2181
+ const args = [sourceExpr, renderFn];
2182
+ if (keyExpr) {
2183
+ args.push(t.objectExpression([
2184
+ t.objectProperty(t.identifier('key'), keyExpr)
2185
+ ]));
2186
+ }
2187
+ return t.callExpression(t.identifier('_$mapArray'), args);
2188
+ };
2189
+
2190
+ if (!fallbackExpr) {
2191
+ return buildMapArrayCall(eachExpr);
1924
2192
  }
1925
- return t.callExpression(t.identifier('_$mapArray'), args);
2193
+
2194
+ // `fallback` used to be read by nobody: the loop above only looked at
2195
+ // `each` and `key`, so the attribute was silently discarded. The RUNTIME
2196
+ // For (packages/core/src/components.js) does implement it, so an app that
2197
+ // moved from a buildless setup to the Vite compiler lost its empty state
2198
+ // with nothing on the console.
2199
+ //
2200
+ // mapArray has no fallback of its own, and the obvious lowering — gate the
2201
+ // WHOLE thing on emptiness, `() => empty() ? _$mapArray(...) : fallback` —
2202
+ // is a trap. `insert()` has a fast path for a mapArray inserter: it
2203
+ // recognises `child._mapArray` and installs the list ONCE for the life of
2204
+ // the component. A thunk is not that, so the gated form falls through to
2205
+ // the generic reactive branch, whose effect calls the thunk again on every
2206
+ // flip and gets a BRAND NEW inserter each time. Nothing disposes the old
2207
+ // one, so it stays subscribed to the source forever: every empty -> fill
2208
+ // cycle permanently adds one more live list. Measured row-render-fn calls
2209
+ // per two writes, over successive cycles, went 2 -> 9 -> 16 -> 23 while
2210
+ // logging "NotFoundError: The child can not be found in the parent" from
2211
+ // the orphans reconciling against the detached fragment they captured. The
2212
+ // DOM stayed correct, which is why it looked fine.
2213
+ //
2214
+ // So keep the list permanent and give the fallback its OWN inserter beside
2215
+ // it, both anchored to the same marker:
2216
+ // _$insert(el, _$mapArray(each, fn), marker) // permanent
2217
+ // _$insert(el, () => empty() ? null : fallback, marker)
2218
+ // Only one of the two ever has content, and each reconciles independently.
2219
+ //
2220
+ // This function has to return a single expression, and a <For> can land in
2221
+ // a component's return, an element child, a component prop or a fragment.
2222
+ // Rather than teach all of those to emit two inserts, wrap the pair in one
2223
+ // inserter that performs both when it is mounted, and copy the list's own
2224
+ // marker properties onto it with Object.assign so it IS a mapArray inserter
2225
+ // to every consumer: `insert`, `createDOM`, `hydrateNode` and the server's
2226
+ // `_mapArrayToArray` all key off `_mapArray` and the `_mapArraySource` /
2227
+ // `_mapArrayFn` / `_mapArrayKeyed` trio. Object.assign copies exactly the
2228
+ // own enumerable properties mapArray sets, so this keeps working if the
2229
+ // runtime adds another one.
2230
+ //
2231
+ // Two more things this has to get right:
2232
+ // - The source expression is evaluated ONCE and shared, so the list and
2233
+ // the emptiness test can never disagree about which list they see.
2234
+ // - The emptiness test is MEMOIZED, so a write that leaves the list
2235
+ // non-empty does not re-run the fallback thunk and rebuild its DOM.
2236
+ if (!state._pendingSetup) state._pendingSetup = [];
2237
+ const forIndex = state.nextForIndex();
2238
+ const eachId = `_each$${forIndex}`;
2239
+ const listId = `_for$${forIndex}`;
2240
+ const fallbackId = `_fb$${forIndex}`;
2241
+ const emptyId = state.nextMemoId();
2242
+ state.needsMemo = true;
2243
+ state.needsInsert = true;
2244
+
2245
+ // `each` has to be a signal, or some other accessor. The typeof guard below
2246
+ // is NOT plain-array support and must not be read as advertising any: it
2247
+ // only keeps this memo total. `_$mapArray` calls its source, so
2248
+ // `each={["a","b"]}` throws "source is not a function" the moment the list
2249
+ // mounts — identically to the no-fallback lowering above, which hands the
2250
+ // same value straight to `_$mapArray`. The guard is here so that failure
2251
+ // arrives from the list, with that message, instead of from the emptiness
2252
+ // test first with a less obvious one.
2253
+ const resolvedId = path.scope
2254
+ ? path.scope.generateUidIdentifier('list')
2255
+ : t.identifier('_list');
2256
+ const resolveList = t.conditionalExpression(
2257
+ t.binaryExpression('===',
2258
+ t.unaryExpression('typeof', t.identifier(eachId)),
2259
+ t.stringLiteral('function')
2260
+ ),
2261
+ t.callExpression(t.identifier(eachId), []),
2262
+ t.identifier(eachId)
2263
+ );
2264
+
2265
+ state._pendingSetup.push(
2266
+ t.variableDeclaration('const', [
2267
+ t.variableDeclarator(t.identifier(eachId), eachExpr)
2268
+ ]),
2269
+ t.variableDeclaration('const', [
2270
+ t.variableDeclarator(
2271
+ t.identifier(emptyId),
2272
+ t.callExpression(t.identifier('_$memo'), [
2273
+ t.arrowFunctionExpression([], t.blockStatement([
2274
+ t.variableDeclaration('const', [
2275
+ t.variableDeclarator(resolvedId, resolveList)
2276
+ ]),
2277
+ t.returnStatement(
2278
+ t.unaryExpression('!', t.unaryExpression('!',
2279
+ t.logicalExpression('&&',
2280
+ t.cloneNode(resolvedId),
2281
+ t.memberExpression(t.cloneNode(resolvedId), t.identifier('length'))
2282
+ )
2283
+ ))
2284
+ )
2285
+ ]))
2286
+ ])
2287
+ )
2288
+ ]),
2289
+ t.variableDeclaration('const', [
2290
+ t.variableDeclarator(t.identifier(listId), buildMapArrayCall(t.identifier(eachId)))
2291
+ ]),
2292
+ // The fallback thunk is hoisted rather than written inline in the
2293
+ // inserter below so that the inserter's body mentions no user code at
2294
+ // all. Its parameters can then be given fixed names without any risk of
2295
+ // capturing an identifier the fallback expression wanted from an outer
2296
+ // scope — this function is also called with a synthetic `{ node }` path
2297
+ // that carries no scope to generate a unique name from.
2298
+ t.variableDeclaration('const', [
2299
+ t.variableDeclarator(
2300
+ t.identifier(fallbackId),
2301
+ t.arrowFunctionExpression([], t.conditionalExpression(
2302
+ t.callExpression(t.identifier(emptyId), []),
2303
+ t.nullLiteral(),
2304
+ fallbackExpr
2305
+ ))
2306
+ )
2307
+ ])
2308
+ );
2309
+
2310
+ const parentParam = t.identifier('_parent');
2311
+ const markerParam = t.identifier('_marker');
2312
+ const endId = t.identifier('_end');
2313
+ return t.callExpression(
2314
+ t.memberExpression(t.identifier('Object'), t.identifier('assign')),
2315
+ [
2316
+ t.arrowFunctionExpression(
2317
+ [parentParam, markerParam],
2318
+ t.blockStatement([
2319
+ // The list goes in first so its `<!--/list-->` end marker sits
2320
+ // BEFORE the fallback: rows render above the empty state, and the
2321
+ // return value stays the end marker every other mapArray inserter
2322
+ // hands back.
2323
+ t.variableDeclaration('const', [
2324
+ t.variableDeclarator(endId, t.callExpression(t.identifier(listId), [
2325
+ t.cloneNode(parentParam),
2326
+ t.cloneNode(markerParam)
2327
+ ]))
2328
+ ]),
2329
+ // The thunk is wrapped in an ARRAY on purpose. `insert()` has two
2330
+ // ways to mount a function and only one of them survives here:
2331
+ // - passed bare, it takes insert's own reactive branch, whose
2332
+ // effect closes over the `parent` it was handed. When this
2333
+ // inserter is mounted through createDOM (a <For> as a component's
2334
+ // return value, at a fragment root, or inside a <Show> arm)
2335
+ // that parent is a throwaway DocumentFragment which is emptied
2336
+ // into the real container straight after. Every later run then
2337
+ // reconciles against a detached node, so the fallback was never
2338
+ // removed once rows arrived and stayed on screen underneath them.
2339
+ // - wrapped in an array, it goes through valuesToNodes -> createDOM,
2340
+ // whose reactive branch re-reads `endMarker.parentNode` on every
2341
+ // run. That is the same live-parent resolution mapArray does for
2342
+ // exactly this reason, so both halves survive being moved.
2343
+ t.expressionStatement(t.callExpression(t.identifier('_$insert'), [
2344
+ t.cloneNode(parentParam),
2345
+ t.arrayExpression([t.identifier(fallbackId)]),
2346
+ t.cloneNode(markerParam)
2347
+ ])),
2348
+ t.returnStatement(t.cloneNode(endId))
2349
+ ])
2350
+ ),
2351
+ t.identifier(listId)
2352
+ ]
2353
+ );
1926
2354
  }
1927
2355
 
1928
2356
  function transformShowFineGrained(path, state) {
@@ -1946,8 +2374,13 @@ export default function whatBabelPlugin({ types: t }) {
1946
2374
  if (!whenExpr) {
1947
2375
  // <Show> without a when prop has no defined semantics — fail loudly at
1948
2376
  // build time so the user fixes their source instead of seeing runtime
1949
- // confusion. buildCodeFrameError pins the error to the JSX location.
1950
- throw path.buildCodeFrameError(
2377
+ // confusion. compileError pins the message to the JSX location from
2378
+ // either a real NodePath or the synthetic `{ node }` a parent transform
2379
+ // passes; calling path.buildCodeFrameError directly crashed on the
2380
+ // second, which is the shape almost every real <Show> arrives as.
2381
+ throw compileError(
2382
+ path,
2383
+ state,
1951
2384
  '<Show> requires a "when" prop. Example: <Show when={isOpen} fallback={null}>...</Show>'
1952
2385
  );
1953
2386
  }
@@ -1963,7 +2396,22 @@ export default function whatBabelPlugin({ types: t }) {
1963
2396
  }
1964
2397
 
1965
2398
  if (!contentExpr) {
1966
- // Static children — collect and transform them
2399
+ // Static children — collect and transform them.
2400
+ //
2401
+ // The arm's element setup belongs INSIDE the arm, exactly as it does for
2402
+ // a <Match> arm (see the identical splice in transformSwitchFineGrained).
2403
+ // transformElementFineGrained pushes `const _el$N = _tmpl$X()` and every
2404
+ // binding it needs into state._pendingSetup, and _pendingSetup is drained
2405
+ // next to the thunk this function returns. So without the splice the arm
2406
+ // was built, and its bindings ran, alongside the enclosing component:
2407
+ // <Show when={user}><p>{() => user().name}</p></Show>
2408
+ // threw at first render with a null user, and logged an uncaught effect
2409
+ // error on every transition back to falsy because the binding stayed
2410
+ // subscribed. Uncompiled, the same JSX is lazy — children of the runtime
2411
+ // Show really are deferred — so this was a compiled-vs-runtime
2412
+ // divergence, not just a bug.
2413
+ if (!state._pendingSetup) state._pendingSetup = [];
2414
+ const setupMark = state._pendingSetup.length;
1967
2415
  const transformedChildren = [];
1968
2416
  for (const child of children) {
1969
2417
  if (t.isJSXText(child)) {
@@ -1980,6 +2428,13 @@ export default function whatBabelPlugin({ types: t }) {
1980
2428
  } else {
1981
2429
  contentExpr = t.nullLiteral();
1982
2430
  }
2431
+ const setup = state._pendingSetup.splice(setupMark);
2432
+ if (setup.length > 0) {
2433
+ contentExpr = t.callExpression(
2434
+ t.arrowFunctionExpression([], t.blockStatement([...setup, t.returnStatement(contentExpr)])),
2435
+ []
2436
+ );
2437
+ }
1983
2438
  }
1984
2439
 
1985
2440
  // Build:
@@ -2108,12 +2563,16 @@ export default function whatBabelPlugin({ types: t }) {
2108
2563
  }
2109
2564
 
2110
2565
  const unsupported = (why) => {
2111
- const message =
2566
+ // The guard this used to carry (`path.buildCodeFrameError ? … : new
2567
+ // Error(…)`) did keep the message reaching the developer from a
2568
+ // synthetic path, but dropped the file and the line with it. compileError
2569
+ // keeps both.
2570
+ throw compileError(
2571
+ path,
2572
+ state,
2112
2573
  `<Switch> ${why}. The compiler needs to read its arms statically. ` +
2113
- 'Write them out: <Switch fallback={...}><Match when={a}>…</Match><Match when={b}>…</Match></Switch>';
2114
- throw path.buildCodeFrameError
2115
- ? path.buildCodeFrameError(message)
2116
- : new Error(`[what-compiler] ${message}`);
2574
+ 'Write them out: <Switch fallback={...}><Match when={a}>…</Match><Match when={b}>…</Match></Switch>'
2575
+ );
2117
2576
  };
2118
2577
 
2119
2578
  let fallbackExpr = null;
@@ -2349,10 +2808,19 @@ export default function whatBabelPlugin({ types: t }) {
2349
2808
  // hoist references to closure variables out of scope.
2350
2809
  let stmtPath = path;
2351
2810
  let crossedFunctionBoundary = false;
2811
+ // Setup hoisted out of a guard runs whether the guard opens or not.
2812
+ // `return user() && <p>{user().name}</p>` is the React shape everybody
2813
+ // writes, and hoisting built the <p> and ran `() => user().name` at
2814
+ // mount, so the very first render threw on a null user. Same for a
2815
+ // ternary arm and for a guarded arm sitting in a component prop.
2816
+ // A guarded arm has to be CONSTRUCTED INSIDE its guard, which is exactly
2817
+ // what the IIFE path below does.
2818
+ let crossedGuard = false;
2352
2819
  while (stmtPath && !stmtPath.isStatement()) {
2353
2820
  if (stmtPath.isArrowFunctionExpression() || stmtPath.isFunctionExpression()) {
2354
2821
  crossedFunctionBoundary = true;
2355
2822
  }
2823
+ if (isConditionallyEvaluated(stmtPath)) crossedGuard = true;
2356
2824
  stmtPath = stmtPath.parentPath;
2357
2825
  }
2358
2826
  // We can safely hoist setup as siblings of `stmtPath` ONLY if
@@ -2373,15 +2841,16 @@ export default function whatBabelPlugin({ types: t }) {
2373
2841
  && stmtPath.isStatement()
2374
2842
  && (stmtPath.listKey === 'body' || stmtPath.listKey === 'consequent')
2375
2843
  && Array.isArray(stmtPath.container);
2376
- if (inStatementList && !crossedFunctionBoundary) {
2844
+ if (inStatementList && !crossedFunctionBoundary && !crossedGuard) {
2377
2845
  // Same function scope — safe to hoist setup before the enclosing
2378
2846
  // statement. Works for return statements too: `insertBefore`
2379
2847
  // places setup above `return <jsx/>` without wrapping in an IIFE.
2380
2848
  stmtPath.insertBefore(pending);
2381
2849
  path.replaceWith(transformed);
2382
2850
  } else {
2383
- // Crossed a function boundary or no enclosing statement found —
2384
- // fall back to IIFE so closure variables remain in scope.
2851
+ // Crossed a function boundary or a guard, or no enclosing statement
2852
+ // was found — fall back to IIFE, which keeps closure variables in
2853
+ // scope AND keeps the construction where the source put it.
2385
2854
  pending.push(t.returnStatement(transformed));
2386
2855
  path.replaceWith(
2387
2856
  t.callExpression(
@@ -2537,9 +3006,16 @@ export default function whatBabelPlugin({ types: t }) {
2537
3006
  state.templateCount = 0;
2538
3007
  state._varCounter = 0;
2539
3008
  state._memoCounter = 0;
3009
+ // Separate counter from nextVarId: a <For each> source is not an
3010
+ // element, and calling it _el$N would make the emitted code lie.
3011
+ // One index names the whole group a <For fallback> lowers to, so the
3012
+ // source (_each$N), the list inserter (_for$N) it feeds and the
3013
+ // fallback thunk (_fb$N) beside it are visibly the same <For>.
3014
+ state._listCounter = 0;
2540
3015
  state._pendingSetup = [];
2541
3016
  state.nextVarId = () => `_el$${state._varCounter++}`;
2542
3017
  state.nextMemoId = () => `_c$${state._memoCounter++}`;
3018
+ state.nextForIndex = () => state._listCounter++;
2543
3019
 
2544
3020
  state.serverActionBindings = new Set();
2545
3021
  state.serverActionNamespaces = new Set();