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.
- package/dist/babel-plugin.min.js +1 -1
- package/dist/file-router.min.js +13 -3
- package/dist/index.min.js +16 -6
- package/dist/vite-plugin.min.js +16 -6
- package/package.json +2 -2
- package/src/babel-plugin.js +495 -19
- package/src/file-router.js +252 -19
package/src/babel-plugin.js
CHANGED
|
@@ -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
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
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
|
-
|
|
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.
|
|
1950
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2384
|
-
// fall back to IIFE
|
|
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();
|