@pyreon/compiler 0.14.0 → 0.15.0
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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +225 -11
- package/lib/types/index.d.ts +13 -1
- package/package.json +10 -2
- package/src/event-names.ts +65 -0
- package/src/jsx.ts +140 -7
- package/src/pyreon-intercept.ts +226 -2
- package/src/tests/detector-tag-consistency.test.ts +3 -0
- package/src/tests/jsx.test.ts +236 -4
- package/src/tests/native-equivalence.test.ts +77 -0
- package/src/tests/pyreon-intercept.test.ts +155 -0
- package/src/tests/runtime/control-flow.test.ts +159 -0
- package/src/tests/runtime/dom-properties.test.ts +138 -0
- package/src/tests/runtime/events.test.ts +301 -0
- package/src/tests/runtime/harness.ts +94 -0
- package/src/tests/runtime/pr-352-shapes.test.ts +121 -0
- package/src/tests/runtime/reactive-props.test.ts +81 -0
- package/src/tests/runtime/signals.test.ts +129 -0
- package/src/tests/runtime/whitespace.test.ts +106 -0
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"7624ee76-1","name":"event-names.ts"},{"uid":"7624ee76-3","name":"jsx.ts"},{"uid":"7624ee76-5","name":"project-scanner.ts"},{"uid":"7624ee76-7","name":"react-intercept.ts"},{"uid":"7624ee76-9","name":"pyreon-intercept.ts"},{"uid":"7624ee76-11","name":"test-audit.ts"},{"uid":"7624ee76-13","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"7624ee76-1":{"renderedLength":2941,"gzipLength":1335,"brotliLength":0,"metaUid":"7624ee76-0"},"7624ee76-3":{"renderedLength":43854,"gzipLength":10520,"brotliLength":0,"metaUid":"7624ee76-2"},"7624ee76-5":{"renderedLength":4762,"gzipLength":1730,"brotliLength":0,"metaUid":"7624ee76-4"},"7624ee76-7":{"renderedLength":27698,"gzipLength":6923,"brotliLength":0,"metaUid":"7624ee76-6"},"7624ee76-9":{"renderedLength":20090,"gzipLength":6573,"brotliLength":0,"metaUid":"7624ee76-8"},"7624ee76-11":{"renderedLength":13163,"gzipLength":5058,"brotliLength":0,"metaUid":"7624ee76-10"},"7624ee76-13":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"7624ee76-12"}},"nodeMetas":{"7624ee76-0":{"id":"/src/event-names.ts","moduleParts":{"index.js":"7624ee76-1"},"imported":[],"importedBy":[{"uid":"7624ee76-2"}]},"7624ee76-2":{"id":"/src/jsx.ts","moduleParts":{"index.js":"7624ee76-3"},"imported":[{"uid":"7624ee76-14"},{"uid":"7624ee76-15"},{"uid":"7624ee76-16"},{"uid":"7624ee76-17"},{"uid":"7624ee76-0"}],"importedBy":[{"uid":"7624ee76-12"}]},"7624ee76-4":{"id":"/src/project-scanner.ts","moduleParts":{"index.js":"7624ee76-5"},"imported":[{"uid":"7624ee76-18"},{"uid":"7624ee76-17"}],"importedBy":[{"uid":"7624ee76-12"}]},"7624ee76-6":{"id":"/src/react-intercept.ts","moduleParts":{"index.js":"7624ee76-7"},"imported":[{"uid":"7624ee76-19"}],"importedBy":[{"uid":"7624ee76-12"}]},"7624ee76-8":{"id":"/src/pyreon-intercept.ts","moduleParts":{"index.js":"7624ee76-9"},"imported":[{"uid":"7624ee76-19"}],"importedBy":[{"uid":"7624ee76-12"}]},"7624ee76-10":{"id":"/src/test-audit.ts","moduleParts":{"index.js":"7624ee76-11"},"imported":[{"uid":"7624ee76-18"},{"uid":"7624ee76-17"}],"importedBy":[{"uid":"7624ee76-12"}]},"7624ee76-12":{"id":"/src/index.ts","moduleParts":{"index.js":"7624ee76-13"},"imported":[{"uid":"7624ee76-2"},{"uid":"7624ee76-4"},{"uid":"7624ee76-6"},{"uid":"7624ee76-8"},{"uid":"7624ee76-10"}],"importedBy":[],"isEntry":true},"7624ee76-14":{"id":"oxc-parser","moduleParts":{},"imported":[],"importedBy":[{"uid":"7624ee76-2"}]},"7624ee76-15":{"id":"node:module","moduleParts":{},"imported":[],"importedBy":[{"uid":"7624ee76-2"}]},"7624ee76-16":{"id":"node:url","moduleParts":{},"imported":[],"importedBy":[{"uid":"7624ee76-2"}]},"7624ee76-17":{"id":"node:path","moduleParts":{},"imported":[],"importedBy":[{"uid":"7624ee76-2"},{"uid":"7624ee76-4"},{"uid":"7624ee76-10"}]},"7624ee76-18":{"id":"node:fs","moduleParts":{},"imported":[],"importedBy":[{"uid":"7624ee76-4"},{"uid":"7624ee76-10"}]},"7624ee76-19":{"id":"typescript","moduleParts":{},"imported":[],"importedBy":[{"uid":"7624ee76-6"},{"uid":"7624ee76-8"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
package/lib/index.js
CHANGED
|
@@ -7,6 +7,56 @@ import * as fs from "node:fs";
|
|
|
7
7
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
8
8
|
import ts from "typescript";
|
|
9
9
|
|
|
10
|
+
//#region src/event-names.ts
|
|
11
|
+
/**
|
|
12
|
+
* React-style → DOM event-name remap.
|
|
13
|
+
*
|
|
14
|
+
* The compiler translates JSX event handler attributes (`onClick`,
|
|
15
|
+
* `onMouseEnter`, ...) to DOM event names by stripping the `on` prefix
|
|
16
|
+
* and lowercasing. That rule covers MOST React event-name conventions
|
|
17
|
+
* because the underlying DOM event name happens to be the lowercased
|
|
18
|
+
* multi-word form (e.g. `onKeyDown` → `keydown`, `onMouseEnter` →
|
|
19
|
+
* `mouseenter`, `onPointerLeave` → `pointerleave`,
|
|
20
|
+
* `onAnimationStart` → `animationstart`, `onContextMenu` → `contextmenu`).
|
|
21
|
+
*
|
|
22
|
+
* **The exceptions** — where lowercasing produces the WRONG DOM event
|
|
23
|
+
* name — are listed in `REACT_EVENT_REMAP` below. Each entry maps the
|
|
24
|
+
* lowercased React form to the actual DOM event name.
|
|
25
|
+
*
|
|
26
|
+
* Today there is exactly ONE remap: `doubleclick → dblclick`. React
|
|
27
|
+
* inherits this mismatch from the DOM spec — `dblclick` is the canonical
|
|
28
|
+
* event name (RFC at `https://dom.spec.whatwg.org/#interface-mouseevent`),
|
|
29
|
+
* while React's component-prop convention is the `onDoubleClick` shape.
|
|
30
|
+
*
|
|
31
|
+
* **Audit completeness.** The full React event-prop list from
|
|
32
|
+
* `https://react.dev/reference/react-dom/components/common` was checked
|
|
33
|
+
* against canonical DOM event names. Every multi-word event other than
|
|
34
|
+
* `onDoubleClick` lowercases correctly:
|
|
35
|
+
* - Pointer family: `onPointerDown` → `pointerdown`, `onGotPointerCapture` → `gotpointercapture`, …
|
|
36
|
+
* - Mouse family: `onMouseEnter` → `mouseenter`, `onMouseLeave` → `mouseleave`, …
|
|
37
|
+
* - Drag family: `onDragStart` → `dragstart`, `onDragEnd` → `dragend`, …
|
|
38
|
+
* - Touch family: `onTouchStart` → `touchstart`, `onTouchEnd` → `touchend`, …
|
|
39
|
+
* - Composition family: `onCompositionEnd` → `compositionend`, …
|
|
40
|
+
* - Animation/transition: `onAnimationStart` → `animationstart`, `onTransitionEnd` → `transitionend`, …
|
|
41
|
+
* - Media family: `onCanPlayThrough` → `canplaythrough`, `onLoadedData` → `loadeddata`, `onTimeUpdate` → `timeupdate`, `onVolumeChange` → `volumechange`, …
|
|
42
|
+
* - Form family: `onContextMenu` → `contextmenu`, `onBeforeInput` → `beforeinput`, …
|
|
43
|
+
*
|
|
44
|
+
* If a future React release adds a new event-prop with a non-trivial
|
|
45
|
+
* mismatch, append the entry here. Both compiler backends (JS and Rust)
|
|
46
|
+
* read the same shape — the Rust port lives in `native/src/lib.rs` next
|
|
47
|
+
* to `emit_event_listener`. Keep them in sync.
|
|
48
|
+
*
|
|
49
|
+
* **Testing.** `packages/core/compiler/src/tests/runtime/events.test.ts`
|
|
50
|
+
* exercises this table end-to-end via a real-Chromium harness:
|
|
51
|
+
* - `onDoubleClick fires (multi-word + delegated)` — locks in the remap.
|
|
52
|
+
* - `onContextMenu fires (multi-word, lowercases to contextmenu)` —
|
|
53
|
+
* locks in the no-remap default for an adjacent multi-word event.
|
|
54
|
+
* - `event-name-remap-table sanity` — asserts that every entry in
|
|
55
|
+
* `REACT_EVENT_REMAP` has a corresponding runtime test.
|
|
56
|
+
*/
|
|
57
|
+
const REACT_EVENT_REMAP = Object.freeze({ doubleclick: "dblclick" });
|
|
58
|
+
|
|
59
|
+
//#endregion
|
|
10
60
|
//#region src/jsx.ts
|
|
11
61
|
/**
|
|
12
62
|
* JSX transform — wraps dynamic JSX expressions in `() =>` so the Pyreon runtime
|
|
@@ -661,7 +711,8 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
661
711
|
else bindLines.push(`{ const __r = ${sliceExpr(expr)}; if (typeof __r === "function") __r(${varName}); else if (__r) __r.current = ${varName} }`);
|
|
662
712
|
}
|
|
663
713
|
function emitEventListener(attr, attrName, varName) {
|
|
664
|
-
const
|
|
714
|
+
const lowered = attrName.slice(2).toLowerCase();
|
|
715
|
+
const eventName = REACT_EVENT_REMAP[lowered] ?? lowered;
|
|
665
716
|
if (!attr.value || attr.value.type !== "JSXExpressionContainer") return;
|
|
666
717
|
const expr = attr.value.expression;
|
|
667
718
|
if (!expr || expr.type === "JSXEmptyExpression") return;
|
|
@@ -702,6 +753,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
702
753
|
function attrSetter(htmlAttrName, varName, expr) {
|
|
703
754
|
if (htmlAttrName === "class") return `${varName}.className = ${expr}`;
|
|
704
755
|
if (htmlAttrName === "style") return `${varName}.style.cssText = ${expr}`;
|
|
756
|
+
if (DOM_PROPS.has(htmlAttrName)) return `${varName}.${htmlAttrName} = ${expr}`;
|
|
705
757
|
return `${varName}.setAttribute("${htmlAttrName}", ${expr})`;
|
|
706
758
|
}
|
|
707
759
|
function emitDynamicAttr(_expr, exprNode, htmlAttrName, varName) {
|
|
@@ -714,7 +766,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
714
766
|
if (directRef) {
|
|
715
767
|
needsBindDirectImport = true;
|
|
716
768
|
const d = nextDisp();
|
|
717
|
-
const updater = htmlAttrName === "class" ? `(v) => { ${varName}.className = v == null ? "" : String(v) }` : htmlAttrName === "style" ? `(v) => { if (typeof v === "string") ${varName}.style.cssText = v; else if (v) Object.assign(${varName}.style, v) }` : `(v) => { ${varName}.setAttribute("${htmlAttrName}", v == null ? "" : String(v)) }`;
|
|
769
|
+
const updater = htmlAttrName === "class" ? `(v) => { ${varName}.className = v == null ? "" : String(v) }` : htmlAttrName === "style" ? `(v) => { if (typeof v === "string") ${varName}.style.cssText = v; else if (v) Object.assign(${varName}.style, v) }` : DOM_PROPS.has(htmlAttrName) ? `(v) => { ${varName}.${htmlAttrName} = v }` : `(v) => { ${varName}.setAttribute("${htmlAttrName}", v == null ? "" : String(v)) }`;
|
|
718
770
|
bindLines.push(`const ${d} = _bindDirect(${directRef}, ${updater})`);
|
|
719
771
|
return;
|
|
720
772
|
}
|
|
@@ -798,10 +850,10 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
798
850
|
}
|
|
799
851
|
function classifyJsxChild(child, out, elemIdxRef, recurse) {
|
|
800
852
|
if (child.type === "JSXText") {
|
|
801
|
-
const
|
|
802
|
-
if (
|
|
853
|
+
const cleaned = cleanJsxText(child.value ?? child.raw ?? "");
|
|
854
|
+
if (cleaned) out.push({
|
|
803
855
|
kind: "text",
|
|
804
|
-
text:
|
|
856
|
+
text: cleaned
|
|
805
857
|
});
|
|
806
858
|
return;
|
|
807
859
|
}
|
|
@@ -834,10 +886,10 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
834
886
|
}
|
|
835
887
|
function analyzeChildren(flatChildren) {
|
|
836
888
|
const hasElem = flatChildren.some((c) => c.kind === "element");
|
|
837
|
-
const
|
|
889
|
+
const hasText = flatChildren.some((c) => c.kind === "text");
|
|
838
890
|
const exprCount = flatChildren.filter((c) => c.kind === "expression").length;
|
|
839
891
|
return {
|
|
840
|
-
useMixed: hasElem
|
|
892
|
+
useMixed: (hasElem ? 1 : 0) + (hasText ? 1 : 0) + (exprCount > 0 ? 1 : 0) > 1,
|
|
841
893
|
useMultiExpr: exprCount > 1
|
|
842
894
|
};
|
|
843
895
|
}
|
|
@@ -914,7 +966,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
914
966
|
bindLines.push(`const ${combinedName} = _bind(() => { ${combinedBody} })`);
|
|
915
967
|
}
|
|
916
968
|
if (bindLines.length === 0 && disposerNames.length === 0) return `_tpl("${escaped}", () => null)`;
|
|
917
|
-
let body = bindLines.map((l) => ` ${l}
|
|
969
|
+
let body = bindLines.map((l) => ` ${l};`).join("\n");
|
|
918
970
|
if (disposerNames.length > 0) body += `\n return () => { ${disposerNames.map((d) => `${d}()`).join("; ")} }`;
|
|
919
971
|
else body += "\n return null";
|
|
920
972
|
return `_tpl("${escaped}", (__root) => {\n${body}\n})`;
|
|
@@ -934,6 +986,10 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
934
986
|
if (node.type === "Identifier" && isActiveSignal(node.name)) {
|
|
935
987
|
const parent = findParent(node);
|
|
936
988
|
if (parent && parent.type === "MemberExpression" && parent.property === node && !parent.computed) return false;
|
|
989
|
+
if (parent && parent.type === "MemberExpression" && parent.object === node) {
|
|
990
|
+
const grand = findParent(parent);
|
|
991
|
+
if (grand && grand.type === "CallExpression" && grand.callee === parent) return false;
|
|
992
|
+
}
|
|
937
993
|
if (parent && parent.type === "CallExpression" && parent.callee === node) return false;
|
|
938
994
|
return true;
|
|
939
995
|
}
|
|
@@ -955,6 +1011,10 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
|
|
|
955
1011
|
if (node.type === "Identifier" && isActiveSignal(node.name)) {
|
|
956
1012
|
const parent = findParent(node);
|
|
957
1013
|
if (parent && parent.type === "MemberExpression" && parent.property === node && !parent.computed) return;
|
|
1014
|
+
if (parent && parent.type === "MemberExpression" && parent.object === node) {
|
|
1015
|
+
const grand = findParent(parent);
|
|
1016
|
+
if (grand && grand.type === "CallExpression" && grand.callee === parent) return;
|
|
1017
|
+
}
|
|
958
1018
|
if (parent && parent.type === "CallExpression" && parent.callee === node) return;
|
|
959
1019
|
if (parent && parent.type === "VariableDeclarator" && parent.id === node) return;
|
|
960
1020
|
if (parent && (parent.type === "Property" || parent.type === "ObjectProperty")) {
|
|
@@ -1002,6 +1062,15 @@ const JSX_TO_HTML_ATTR = {
|
|
|
1002
1062
|
className: "class",
|
|
1003
1063
|
htmlFor: "for"
|
|
1004
1064
|
};
|
|
1065
|
+
const DOM_PROPS = new Set([
|
|
1066
|
+
"value",
|
|
1067
|
+
"checked",
|
|
1068
|
+
"selected",
|
|
1069
|
+
"disabled",
|
|
1070
|
+
"multiple",
|
|
1071
|
+
"readOnly",
|
|
1072
|
+
"indeterminate"
|
|
1073
|
+
]);
|
|
1005
1074
|
const STATEFUL_CALLS = new Set([
|
|
1006
1075
|
"signal",
|
|
1007
1076
|
"computed",
|
|
@@ -1054,6 +1123,23 @@ function escapeHtmlAttr(s) {
|
|
|
1054
1123
|
function escapeHtmlText(s) {
|
|
1055
1124
|
return s.replace(/&(?!(?:#\d+|#x[\da-fA-F]+|[a-zA-Z]\w*);)/g, "&").replace(/</g, "<");
|
|
1056
1125
|
}
|
|
1126
|
+
function cleanJsxText(raw) {
|
|
1127
|
+
if (!raw.includes("\n") && !raw.includes("\r")) return raw;
|
|
1128
|
+
const lines = raw.split(/\r\n|\n|\r/);
|
|
1129
|
+
let lastNonEmpty = -1;
|
|
1130
|
+
for (let i = 0; i < lines.length; i++) if (/[^ \t]/.test(lines[i] ?? "")) lastNonEmpty = i;
|
|
1131
|
+
let str = "";
|
|
1132
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1133
|
+
let line = (lines[i] ?? "").replace(/\t/g, " ");
|
|
1134
|
+
if (i !== 0) line = line.replace(/^ +/, "");
|
|
1135
|
+
if (i !== lines.length - 1) line = line.replace(/ +$/, "");
|
|
1136
|
+
if (line) {
|
|
1137
|
+
if (i !== lastNonEmpty) line += " ";
|
|
1138
|
+
str += line;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
return str;
|
|
1142
|
+
}
|
|
1057
1143
|
function isStaticJSXNode(node) {
|
|
1058
1144
|
if (node.type === "JSXElement" && node.openingElement?.selfClosing) return isStaticAttrs(node.openingElement.attributes ?? []);
|
|
1059
1145
|
if (node.type === "JSXFragment") return (node.children ?? []).every(isStaticChild);
|
|
@@ -2004,6 +2090,18 @@ function diagnoseError(error) {
|
|
|
2004
2090
|
* monotonic counter.
|
|
2005
2091
|
* - `on-click-undefined` — `onClick={undefined}` explicitly; the runtime
|
|
2006
2092
|
* used to crash on this pattern. Omit the prop.
|
|
2093
|
+
* - `signal-write-as-call` — `sig(value)` is a no-op read that ignores
|
|
2094
|
+
* its argument; the runtime warns in dev. Static
|
|
2095
|
+
* detector spots it pre-runtime when `sig` was
|
|
2096
|
+
* declared as `const sig = signal(...)` /
|
|
2097
|
+
* `computed(...)` and called with ≥1 argument.
|
|
2098
|
+
* - `static-return-null-conditional` — `if (cond) return null` at the
|
|
2099
|
+
* top of a component body runs ONCE; signal changes
|
|
2100
|
+
* in `cond` never re-evaluate the early-return.
|
|
2101
|
+
* Wrap in a returned reactive accessor.
|
|
2102
|
+
* - `as-unknown-as-vnodechild` — defensive `as unknown as VNodeChild`
|
|
2103
|
+
* cast on JSX returns is unnecessary (`JSX.Element`
|
|
2104
|
+
* is already assignable to `VNodeChild`).
|
|
2007
2105
|
*
|
|
2008
2106
|
* Two-mode surface mirrors `react-intercept.ts`:
|
|
2009
2107
|
* - `detectPyreonPatterns(code)` — diagnostics only
|
|
@@ -2164,9 +2262,122 @@ function detectOnClickUndefined(ctx, node) {
|
|
|
2164
2262
|
if (!(ts.isIdentifier(expr) && expr.text === "undefined" || expr.kind === ts.SyntaxKind.VoidExpression)) return;
|
|
2165
2263
|
pushDiag(ctx, node, "on-click-undefined", `\`${attrName}={undefined}\` explicitly passes undefined as a listener. Pyreon's runtime guards against this, but the cleanest pattern is to omit the attribute entirely or use a conditional: \`${attrName}={condition ? handler : undefined}\`.`, getNodeText(ctx, node), `/* omit ${attrName} when the handler is not defined */`, false);
|
|
2166
2264
|
}
|
|
2265
|
+
/**
|
|
2266
|
+
* Walks the file and collects every identifier bound to a `signal(...)` or
|
|
2267
|
+
* `computed(...)` call. Only `const` declarations are tracked — `let`/`var`
|
|
2268
|
+
* may be reassigned to non-signal values, so a use-site call wouldn't be a
|
|
2269
|
+
* reliable signal-write.
|
|
2270
|
+
*
|
|
2271
|
+
* The collection is intentionally scope-blind: a name shadowed in a nested
|
|
2272
|
+
* scope (`const x = signal(0); function f() { const x = 5; x(7) }`) would
|
|
2273
|
+
* produce a false positive on `x(7)`. That tradeoff is acceptable because
|
|
2274
|
+
* (1) shadowing a signal name with a non-signal is itself unusual and
|
|
2275
|
+
* (2) the detector message points at exactly the wrong-shape call so a
|
|
2276
|
+
* human reviewer can dismiss the rare false positive in seconds.
|
|
2277
|
+
*/
|
|
2278
|
+
function collectSignalBindings(sf) {
|
|
2279
|
+
const names = /* @__PURE__ */ new Set();
|
|
2280
|
+
function isSignalFactoryCall(init) {
|
|
2281
|
+
if (!init || !ts.isCallExpression(init)) return false;
|
|
2282
|
+
const callee = init.expression;
|
|
2283
|
+
if (!ts.isIdentifier(callee)) return false;
|
|
2284
|
+
return callee.text === "signal" || callee.text === "computed";
|
|
2285
|
+
}
|
|
2286
|
+
function walk(node) {
|
|
2287
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
2288
|
+
const list = node.parent;
|
|
2289
|
+
if (ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0 && isSignalFactoryCall(node.initializer)) names.add(node.name.text);
|
|
2290
|
+
}
|
|
2291
|
+
ts.forEachChild(node, walk);
|
|
2292
|
+
}
|
|
2293
|
+
walk(sf);
|
|
2294
|
+
return names;
|
|
2295
|
+
}
|
|
2296
|
+
function detectSignalWriteAsCall(ctx, node) {
|
|
2297
|
+
if (ctx.signalBindings.size === 0) return;
|
|
2298
|
+
const callee = node.expression;
|
|
2299
|
+
if (!ts.isIdentifier(callee)) return;
|
|
2300
|
+
if (!ctx.signalBindings.has(callee.text)) return;
|
|
2301
|
+
if (node.arguments.length === 0) return;
|
|
2302
|
+
pushDiag(ctx, node, "signal-write-as-call", `\`${callee.text}(value)\` does NOT write the signal — \`signal()\` is the read-only callable surface and ignores its arguments. Use \`${callee.text}.set(value)\` to assign or \`${callee.text}.update((prev) => …)\` to derive from the previous value. Pyreon's runtime warns about this pattern in dev, but the warning fires AFTER the silent no-op.`, getNodeText(ctx, node), `${callee.text}.set(${node.arguments.map((a) => getNodeText(ctx, a)).join(", ")})`, false);
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* `if (cond) return null` at the top of a component body runs ONCE — Pyreon
|
|
2306
|
+
* components mount and never re-execute their function bodies. A signal
|
|
2307
|
+
* change inside `cond` therefore never re-evaluates the condition; the
|
|
2308
|
+
* component is permanently stuck on whichever branch the first run picked.
|
|
2309
|
+
*
|
|
2310
|
+
* The fix is to wrap the conditional in a returned reactive accessor:
|
|
2311
|
+
* return (() => { if (!cond()) return null; return <div /> })
|
|
2312
|
+
*
|
|
2313
|
+
* Detection:
|
|
2314
|
+
* - The function contains JSX (i.e. it's a component)
|
|
2315
|
+
* - The function body has an `IfStatement` whose `thenStatement` is
|
|
2316
|
+
* `return null` (either bare `return null` or `{ return null }`)
|
|
2317
|
+
* - The `if` is at the function body's top level, NOT inside a returned
|
|
2318
|
+
* arrow / IIFE (those are reactive scopes — flagging them would be a
|
|
2319
|
+
* false positive)
|
|
2320
|
+
*/
|
|
2321
|
+
function returnsNullStatement(stmt) {
|
|
2322
|
+
if (ts.isReturnStatement(stmt)) {
|
|
2323
|
+
const expr = stmt.expression;
|
|
2324
|
+
return !!expr && expr.kind === ts.SyntaxKind.NullKeyword;
|
|
2325
|
+
}
|
|
2326
|
+
if (ts.isBlock(stmt)) return stmt.statements.length === 1 && returnsNullStatement(stmt.statements[0]);
|
|
2327
|
+
return false;
|
|
2328
|
+
}
|
|
2329
|
+
/**
|
|
2330
|
+
* Returns true if the function looks like a top-level component:
|
|
2331
|
+
* - `function PascalName(...) { ... }` (FunctionDeclaration with PascalCase id), OR
|
|
2332
|
+
* - `const PascalName = (...) => { ... }` (arrow inside a VariableDeclaration whose name is PascalCase).
|
|
2333
|
+
*
|
|
2334
|
+
* Anonymous nested arrows — most importantly the reactive accessor
|
|
2335
|
+
* `return (() => { if (!cond()) return null; return <div /> })` — are
|
|
2336
|
+
* NOT considered components here, even when they contain JSX. Without
|
|
2337
|
+
* this filter the detector would fire on the very pattern the
|
|
2338
|
+
* diagnostic recommends as the fix.
|
|
2339
|
+
*/
|
|
2340
|
+
function isComponentShapedFunction(node) {
|
|
2341
|
+
if (ts.isFunctionDeclaration(node)) return !!node.name && /^[A-Z]/.test(node.name.text);
|
|
2342
|
+
const parent = node.parent;
|
|
2343
|
+
if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return /^[A-Z]/.test(parent.name.text);
|
|
2344
|
+
return false;
|
|
2345
|
+
}
|
|
2346
|
+
function detectStaticReturnNullConditional(ctx, node) {
|
|
2347
|
+
if (!isComponentShapedFunction(node)) return;
|
|
2348
|
+
if (!containsJsx(node)) return;
|
|
2349
|
+
const body = node.body;
|
|
2350
|
+
if (!body || !ts.isBlock(body)) return;
|
|
2351
|
+
for (const stmt of body.statements) {
|
|
2352
|
+
if (!ts.isIfStatement(stmt)) continue;
|
|
2353
|
+
if (!returnsNullStatement(stmt.thenStatement)) continue;
|
|
2354
|
+
pushDiag(ctx, stmt, "static-return-null-conditional", "Pyreon components run ONCE — `if (cond) return null` at the top of a component body is evaluated exactly once at mount. Reading a signal inside `cond` will NOT re-trigger the early return when the signal changes; the component is stuck on whichever branch the first run picked. Wrap the conditional in a returned reactive accessor: `return (() => { if (!cond()) return null; return <div /> })` — the accessor re-runs whenever its tracked signals change.", getNodeText(ctx, stmt), "return (() => { if (!cond()) return null; return <JSX /> })", false);
|
|
2355
|
+
return;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
/**
|
|
2359
|
+
* `JSX.Element` (which is what JSX evaluates to) is already assignable to
|
|
2360
|
+
* `VNodeChild`. The `as unknown as VNodeChild` double-cast is unnecessary
|
|
2361
|
+
* — it's been showing up in `@pyreon/ui-primitives` as a defensive habit
|
|
2362
|
+
* carried over from earlier framework versions. The cast is never load-
|
|
2363
|
+
* bearing today; removing it never changes runtime behavior. Pure cosmetic
|
|
2364
|
+
* but a useful proxy for non-idiomatic Pyreon code in primitives.
|
|
2365
|
+
*/
|
|
2366
|
+
function detectAsUnknownAsVNodeChild(ctx, node) {
|
|
2367
|
+
const outerType = node.type;
|
|
2368
|
+
if (!ts.isTypeReferenceNode(outerType)) return;
|
|
2369
|
+
if (!ts.isIdentifier(outerType.typeName) || outerType.typeName.text !== "VNodeChild") return;
|
|
2370
|
+
const inner = node.expression;
|
|
2371
|
+
if (!ts.isAsExpression(inner)) return;
|
|
2372
|
+
if (inner.type.kind !== ts.SyntaxKind.UnknownKeyword) return;
|
|
2373
|
+
pushDiag(ctx, node, "as-unknown-as-vnodechild", "`as unknown as VNodeChild` is unnecessary — `JSX.Element` (the type produced by JSX) is already assignable to `VNodeChild`. Remove the double cast; it is pure noise that hides genuine type issues if they ever appear at this site.", getNodeText(ctx, node), getNodeText(ctx, inner.expression), false);
|
|
2374
|
+
}
|
|
2167
2375
|
function visitNode(ctx, node) {
|
|
2168
2376
|
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) detectForKeying(ctx, node);
|
|
2169
|
-
if (ts.isArrowFunction(node) || ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node))
|
|
2377
|
+
if (ts.isArrowFunction(node) || ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) {
|
|
2378
|
+
detectPropsDestructured(ctx, node);
|
|
2379
|
+
detectStaticReturnNullConditional(ctx, node);
|
|
2380
|
+
}
|
|
2170
2381
|
if (ts.isBinaryExpression(node)) {
|
|
2171
2382
|
detectProcessDevGate(ctx, node);
|
|
2172
2383
|
detectDateMathRandomId(ctx, node);
|
|
@@ -2175,8 +2386,10 @@ function visitNode(ctx, node) {
|
|
|
2175
2386
|
if (ts.isCallExpression(node)) {
|
|
2176
2387
|
detectEmptyTheme(ctx, node);
|
|
2177
2388
|
detectRawEventListener(ctx, node);
|
|
2389
|
+
detectSignalWriteAsCall(ctx, node);
|
|
2178
2390
|
}
|
|
2179
2391
|
if (ts.isJsxAttribute(node)) detectOnClickUndefined(ctx, node);
|
|
2392
|
+
if (ts.isAsExpression(node)) detectAsUnknownAsVNodeChild(ctx, node);
|
|
2180
2393
|
}
|
|
2181
2394
|
function visit(ctx, node) {
|
|
2182
2395
|
ts.forEachChild(node, (child) => {
|
|
@@ -2189,7 +2402,8 @@ function detectPyreonPatterns(code, filename = "input.tsx") {
|
|
|
2189
2402
|
const ctx = {
|
|
2190
2403
|
sf,
|
|
2191
2404
|
code,
|
|
2192
|
-
diagnostics: []
|
|
2405
|
+
diagnostics: [],
|
|
2406
|
+
signalBindings: collectSignalBindings(sf)
|
|
2193
2407
|
};
|
|
2194
2408
|
visit(ctx, sf);
|
|
2195
2409
|
ctx.diagnostics.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
@@ -2197,7 +2411,7 @@ function detectPyreonPatterns(code, filename = "input.tsx") {
|
|
|
2197
2411
|
}
|
|
2198
2412
|
/** Fast regex pre-filter — returns true if the code is worth a full AST walk. */
|
|
2199
2413
|
function hasPyreonPatterns(code) {
|
|
2200
|
-
return /\bFor\b[^=]*\beach\s*=/.test(code) || /\btypeof\s+process\b/.test(code) || /\.theme\s*\(\s*\{\s*\}\s*\)/.test(code) || /\b(?:add|remove)EventListener\s*\(/.test(code) || /\bDate\.now\s*\(/.test(code) && /\bMath\.random\s*\(/.test(code) || /on[A-Z]\w*\s*=\s*\{\s*undefined\s*\}/.test(code) || /=\s*\(\s*\{[^}]+\}\s*[:)]/.test(code);
|
|
2414
|
+
return /\bFor\b[^=]*\beach\s*=/.test(code) || /\btypeof\s+process\b/.test(code) || /\.theme\s*\(\s*\{\s*\}\s*\)/.test(code) || /\b(?:add|remove)EventListener\s*\(/.test(code) || /\bDate\.now\s*\(/.test(code) && /\bMath\.random\s*\(/.test(code) || /on[A-Z]\w*\s*=\s*\{\s*undefined\s*\}/.test(code) || /=\s*\(\s*\{[^}]+\}\s*[:)]/.test(code) || /\b(?:signal|computed)\s*[<(]/.test(code) || /\bif\s*\([^)]+\)\s*\{?\s*return\s+null\b/.test(code) || /\bas\s+unknown\s+as\s+VNodeChild\b/.test(code);
|
|
2201
2415
|
}
|
|
2202
2416
|
|
|
2203
2417
|
//#endregion
|
package/lib/types/index.d.ts
CHANGED
|
@@ -194,6 +194,18 @@ declare function diagnoseError(error: string): ErrorDiagnosis | null;
|
|
|
194
194
|
* monotonic counter.
|
|
195
195
|
* - `on-click-undefined` — `onClick={undefined}` explicitly; the runtime
|
|
196
196
|
* used to crash on this pattern. Omit the prop.
|
|
197
|
+
* - `signal-write-as-call` — `sig(value)` is a no-op read that ignores
|
|
198
|
+
* its argument; the runtime warns in dev. Static
|
|
199
|
+
* detector spots it pre-runtime when `sig` was
|
|
200
|
+
* declared as `const sig = signal(...)` /
|
|
201
|
+
* `computed(...)` and called with ≥1 argument.
|
|
202
|
+
* - `static-return-null-conditional` — `if (cond) return null` at the
|
|
203
|
+
* top of a component body runs ONCE; signal changes
|
|
204
|
+
* in `cond` never re-evaluate the early-return.
|
|
205
|
+
* Wrap in a returned reactive accessor.
|
|
206
|
+
* - `as-unknown-as-vnodechild` — defensive `as unknown as VNodeChild`
|
|
207
|
+
* cast on JSX returns is unnecessary (`JSX.Element`
|
|
208
|
+
* is already assignable to `VNodeChild`).
|
|
197
209
|
*
|
|
198
210
|
* Two-mode surface mirrors `react-intercept.ts`:
|
|
199
211
|
* - `detectPyreonPatterns(code)` — diagnostics only
|
|
@@ -214,7 +226,7 @@ declare function diagnoseError(error: string): ErrorDiagnosis | null;
|
|
|
214
226
|
* 2. CLI `pyreon doctor`
|
|
215
227
|
* 3. MCP server `validate` tool
|
|
216
228
|
*/
|
|
217
|
-
type PyreonDiagnosticCode = 'for-missing-by' | 'for-with-key' | 'props-destructured' | 'process-dev-gate' | 'empty-theme' | 'raw-add-event-listener' | 'raw-remove-event-listener' | 'date-math-random-id' | 'on-click-undefined';
|
|
229
|
+
type PyreonDiagnosticCode = 'for-missing-by' | 'for-with-key' | 'props-destructured' | 'process-dev-gate' | 'empty-theme' | 'raw-add-event-listener' | 'raw-remove-event-listener' | 'date-math-random-id' | 'on-click-undefined' | 'signal-write-as-call' | 'static-return-null-conditional' | 'as-unknown-as-vnodechild';
|
|
218
230
|
interface PyreonDiagnostic {
|
|
219
231
|
/** Machine-readable code for filtering + programmatic handling */
|
|
220
232
|
code: PyreonDiagnosticCode;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/compiler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Template and JSX compiler for Pyreon",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/compiler#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"lib",
|
|
17
|
+
"!lib/**/*.map",
|
|
17
18
|
"src",
|
|
18
19
|
"README.md",
|
|
19
20
|
"LICENSE"
|
|
@@ -42,7 +43,14 @@
|
|
|
42
43
|
"prepublishOnly": "bun run build"
|
|
43
44
|
},
|
|
44
45
|
"dependencies": {
|
|
45
|
-
"oxc-parser": "^0.
|
|
46
|
+
"oxc-parser": "^0.129.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@pyreon/core": "^0.15.0",
|
|
50
|
+
"@pyreon/reactivity": "^0.15.0",
|
|
51
|
+
"@pyreon/runtime-dom": "^0.15.0",
|
|
52
|
+
"@pyreon/test-utils": "^0.13.2",
|
|
53
|
+
"happy-dom": "^20.8.3"
|
|
46
54
|
},
|
|
47
55
|
"peerDependencies": {
|
|
48
56
|
"typescript": ">=5.0.0"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React-style → DOM event-name remap.
|
|
3
|
+
*
|
|
4
|
+
* The compiler translates JSX event handler attributes (`onClick`,
|
|
5
|
+
* `onMouseEnter`, ...) to DOM event names by stripping the `on` prefix
|
|
6
|
+
* and lowercasing. That rule covers MOST React event-name conventions
|
|
7
|
+
* because the underlying DOM event name happens to be the lowercased
|
|
8
|
+
* multi-word form (e.g. `onKeyDown` → `keydown`, `onMouseEnter` →
|
|
9
|
+
* `mouseenter`, `onPointerLeave` → `pointerleave`,
|
|
10
|
+
* `onAnimationStart` → `animationstart`, `onContextMenu` → `contextmenu`).
|
|
11
|
+
*
|
|
12
|
+
* **The exceptions** — where lowercasing produces the WRONG DOM event
|
|
13
|
+
* name — are listed in `REACT_EVENT_REMAP` below. Each entry maps the
|
|
14
|
+
* lowercased React form to the actual DOM event name.
|
|
15
|
+
*
|
|
16
|
+
* Today there is exactly ONE remap: `doubleclick → dblclick`. React
|
|
17
|
+
* inherits this mismatch from the DOM spec — `dblclick` is the canonical
|
|
18
|
+
* event name (RFC at `https://dom.spec.whatwg.org/#interface-mouseevent`),
|
|
19
|
+
* while React's component-prop convention is the `onDoubleClick` shape.
|
|
20
|
+
*
|
|
21
|
+
* **Audit completeness.** The full React event-prop list from
|
|
22
|
+
* `https://react.dev/reference/react-dom/components/common` was checked
|
|
23
|
+
* against canonical DOM event names. Every multi-word event other than
|
|
24
|
+
* `onDoubleClick` lowercases correctly:
|
|
25
|
+
* - Pointer family: `onPointerDown` → `pointerdown`, `onGotPointerCapture` → `gotpointercapture`, …
|
|
26
|
+
* - Mouse family: `onMouseEnter` → `mouseenter`, `onMouseLeave` → `mouseleave`, …
|
|
27
|
+
* - Drag family: `onDragStart` → `dragstart`, `onDragEnd` → `dragend`, …
|
|
28
|
+
* - Touch family: `onTouchStart` → `touchstart`, `onTouchEnd` → `touchend`, …
|
|
29
|
+
* - Composition family: `onCompositionEnd` → `compositionend`, …
|
|
30
|
+
* - Animation/transition: `onAnimationStart` → `animationstart`, `onTransitionEnd` → `transitionend`, …
|
|
31
|
+
* - Media family: `onCanPlayThrough` → `canplaythrough`, `onLoadedData` → `loadeddata`, `onTimeUpdate` → `timeupdate`, `onVolumeChange` → `volumechange`, …
|
|
32
|
+
* - Form family: `onContextMenu` → `contextmenu`, `onBeforeInput` → `beforeinput`, …
|
|
33
|
+
*
|
|
34
|
+
* If a future React release adds a new event-prop with a non-trivial
|
|
35
|
+
* mismatch, append the entry here. Both compiler backends (JS and Rust)
|
|
36
|
+
* read the same shape — the Rust port lives in `native/src/lib.rs` next
|
|
37
|
+
* to `emit_event_listener`. Keep them in sync.
|
|
38
|
+
*
|
|
39
|
+
* **Testing.** `packages/core/compiler/src/tests/runtime/events.test.ts`
|
|
40
|
+
* exercises this table end-to-end via a real-Chromium harness:
|
|
41
|
+
* - `onDoubleClick fires (multi-word + delegated)` — locks in the remap.
|
|
42
|
+
* - `onContextMenu fires (multi-word, lowercases to contextmenu)` —
|
|
43
|
+
* locks in the no-remap default for an adjacent multi-word event.
|
|
44
|
+
* - `event-name-remap-table sanity` — asserts that every entry in
|
|
45
|
+
* `REACT_EVENT_REMAP` has a corresponding runtime test.
|
|
46
|
+
*/
|
|
47
|
+
export const REACT_EVENT_REMAP: Readonly<Record<string, string>> = Object.freeze({
|
|
48
|
+
doubleclick: 'dblclick',
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Translate a React-style event prop name (`onDoubleClick`) to the
|
|
53
|
+
* canonical DOM event name (`dblclick`). Returns null for non-event
|
|
54
|
+
* attribute names (anything not starting with `on` or shorter than 3
|
|
55
|
+
* characters — single-letter `on*` props don't correspond to DOM events).
|
|
56
|
+
*
|
|
57
|
+
* The compiler uses the returned name in two emission shapes:
|
|
58
|
+
* - Delegated events (in `DELEGATED_EVENTS`): `el.__ev_${eventName} = handler`
|
|
59
|
+
* - Direct listeners: `el.addEventListener("${eventName}", handler)`
|
|
60
|
+
*/
|
|
61
|
+
export function reactEventToDom(attrName: string): string | null {
|
|
62
|
+
if (attrName.length <= 2 || !attrName.startsWith('on')) return null
|
|
63
|
+
const lower = attrName.slice(2).toLowerCase()
|
|
64
|
+
return REACT_EVENT_REMAP[lower] ?? lower
|
|
65
|
+
}
|