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