what-compiler 0.12.3 → 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;
@@ -620,6 +657,111 @@ export default function whatBabelPlugin({ types: t }) {
620
657
  return false;
621
658
  }
622
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
+
623
765
  // --- Auto-lower .map() to mapArray ---
624
766
  // Detects: source().map((item) => <Comp key={expr} .../>)
625
767
  // or wrapped in an arrow: () => source().map(...)
@@ -707,14 +849,23 @@ export default function whatBabelPlugin({ types: t }) {
707
849
  // JSX returned without a key — bail out, but warn at compile time so
708
850
  // users notice they're missing keyed reconciliation. Only warn in dev
709
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.
710
860
  if (process.env.NODE_ENV !== 'production') {
711
861
  const loc = returnExpr.loc;
712
862
  const fileName = state.filename || state.file?.opts?.filename || '<unknown>';
713
863
  const lineInfo = loc ? `:${loc.start.line}:${loc.start.column}` : '';
714
864
  console.warn(
715
- `[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}. ` +
716
866
  `Without a key, the list cannot use keyed reconciliation — items are re-created on every update. ` +
717
- `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.`
718
869
  );
719
870
  }
720
871
  return null;
@@ -724,6 +875,60 @@ export default function whatBabelPlugin({ types: t }) {
724
875
  const keyValue = getAttributeValue(keyAttr.value);
725
876
  if (!keyValue) return null;
726
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
+
727
932
  // Remove the key prop from the JSX element (mapArray handles keying, not the DOM)
728
933
  returnExpr.openingElement.attributes = attrs.filter(a => a !== keyAttr);
729
934
 
@@ -1325,6 +1530,31 @@ export default function whatBabelPlugin({ types: t }) {
1325
1530
  return false;
1326
1531
  }
1327
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
+
1328
1558
  // If `expr` is a conditional (ternary / && / ||) with a reactive test and a
1329
1559
  // DOM-producing branch, hoist the test into `const _c$N = _$memo(...)` (pushed
1330
1560
  // onto `statements`) and return the expression rewritten to read the memo.
@@ -1912,11 +2142,13 @@ export default function whatBabelPlugin({ types: t }) {
1912
2142
 
1913
2143
  let eachExpr = null;
1914
2144
  let keyExpr = null;
2145
+ let fallbackExpr = null;
1915
2146
  for (const attr of attributes) {
1916
2147
  if (t.isJSXAttribute(attr)) {
1917
2148
  const name = getAttrName(attr);
1918
2149
  if (name === 'each') eachExpr = getAttributeValue(attr.value);
1919
2150
  else if (name === 'key') keyExpr = getAttributeValue(attr.value);
2151
+ else if (name === 'fallback') fallbackExpr = getAttributeValue(attr.value);
1920
2152
  }
1921
2153
  }
1922
2154
 
@@ -1941,13 +2173,184 @@ export default function whatBabelPlugin({ types: t }) {
1941
2173
  }
1942
2174
 
1943
2175
  state.needsMapArray = true;
1944
- const args = [eachExpr, renderFn];
1945
- if (keyExpr) {
1946
- args.push(t.objectExpression([
1947
- t.objectProperty(t.identifier('key'), keyExpr)
1948
- ]));
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);
1949
2192
  }
1950
- 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
+ );
1951
2354
  }
1952
2355
 
1953
2356
  function transformShowFineGrained(path, state) {
@@ -1971,8 +2374,13 @@ export default function whatBabelPlugin({ types: t }) {
1971
2374
  if (!whenExpr) {
1972
2375
  // <Show> without a when prop has no defined semantics — fail loudly at
1973
2376
  // build time so the user fixes their source instead of seeing runtime
1974
- // confusion. buildCodeFrameError pins the error to the JSX location.
1975
- 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,
1976
2384
  '<Show> requires a "when" prop. Example: <Show when={isOpen} fallback={null}>...</Show>'
1977
2385
  );
1978
2386
  }
@@ -1988,7 +2396,22 @@ export default function whatBabelPlugin({ types: t }) {
1988
2396
  }
1989
2397
 
1990
2398
  if (!contentExpr) {
1991
- // 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;
1992
2415
  const transformedChildren = [];
1993
2416
  for (const child of children) {
1994
2417
  if (t.isJSXText(child)) {
@@ -2005,6 +2428,13 @@ export default function whatBabelPlugin({ types: t }) {
2005
2428
  } else {
2006
2429
  contentExpr = t.nullLiteral();
2007
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
+ }
2008
2438
  }
2009
2439
 
2010
2440
  // Build:
@@ -2133,12 +2563,16 @@ export default function whatBabelPlugin({ types: t }) {
2133
2563
  }
2134
2564
 
2135
2565
  const unsupported = (why) => {
2136
- 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,
2137
2573
  `<Switch> ${why}. The compiler needs to read its arms statically. ` +
2138
- 'Write them out: <Switch fallback={...}><Match when={a}>…</Match><Match when={b}>…</Match></Switch>';
2139
- throw path.buildCodeFrameError
2140
- ? path.buildCodeFrameError(message)
2141
- : new Error(`[what-compiler] ${message}`);
2574
+ 'Write them out: <Switch fallback={...}><Match when={a}>…</Match><Match when={b}>…</Match></Switch>'
2575
+ );
2142
2576
  };
2143
2577
 
2144
2578
  let fallbackExpr = null;
@@ -2374,10 +2808,19 @@ export default function whatBabelPlugin({ types: t }) {
2374
2808
  // hoist references to closure variables out of scope.
2375
2809
  let stmtPath = path;
2376
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;
2377
2819
  while (stmtPath && !stmtPath.isStatement()) {
2378
2820
  if (stmtPath.isArrowFunctionExpression() || stmtPath.isFunctionExpression()) {
2379
2821
  crossedFunctionBoundary = true;
2380
2822
  }
2823
+ if (isConditionallyEvaluated(stmtPath)) crossedGuard = true;
2381
2824
  stmtPath = stmtPath.parentPath;
2382
2825
  }
2383
2826
  // We can safely hoist setup as siblings of `stmtPath` ONLY if
@@ -2398,15 +2841,16 @@ export default function whatBabelPlugin({ types: t }) {
2398
2841
  && stmtPath.isStatement()
2399
2842
  && (stmtPath.listKey === 'body' || stmtPath.listKey === 'consequent')
2400
2843
  && Array.isArray(stmtPath.container);
2401
- if (inStatementList && !crossedFunctionBoundary) {
2844
+ if (inStatementList && !crossedFunctionBoundary && !crossedGuard) {
2402
2845
  // Same function scope — safe to hoist setup before the enclosing
2403
2846
  // statement. Works for return statements too: `insertBefore`
2404
2847
  // places setup above `return <jsx/>` without wrapping in an IIFE.
2405
2848
  stmtPath.insertBefore(pending);
2406
2849
  path.replaceWith(transformed);
2407
2850
  } else {
2408
- // Crossed a function boundary or no enclosing statement found —
2409
- // 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.
2410
2854
  pending.push(t.returnStatement(transformed));
2411
2855
  path.replaceWith(
2412
2856
  t.callExpression(
@@ -2562,9 +3006,16 @@ export default function whatBabelPlugin({ types: t }) {
2562
3006
  state.templateCount = 0;
2563
3007
  state._varCounter = 0;
2564
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;
2565
3015
  state._pendingSetup = [];
2566
3016
  state.nextVarId = () => `_el$${state._varCounter++}`;
2567
3017
  state.nextMemoId = () => `_c$${state._memoCounter++}`;
3018
+ state.nextForIndex = () => state._listCounter++;
2568
3019
 
2569
3020
  state.serverActionBindings = new Set();
2570
3021
  state.serverActionNamespaces = new Set();