@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.
@@ -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":"106fae60-1","name":"jsx.ts"},{"uid":"106fae60-3","name":"project-scanner.ts"},{"uid":"106fae60-5","name":"react-intercept.ts"},{"uid":"106fae60-7","name":"pyreon-intercept.ts"},{"uid":"106fae60-9","name":"test-audit.ts"},{"uid":"106fae60-11","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"106fae60-1":{"renderedLength":42528,"gzipLength":10166,"brotliLength":0,"metaUid":"106fae60-0"},"106fae60-3":{"renderedLength":4762,"gzipLength":1730,"brotliLength":0,"metaUid":"106fae60-2"},"106fae60-5":{"renderedLength":27698,"gzipLength":6923,"brotliLength":0,"metaUid":"106fae60-4"},"106fae60-7":{"renderedLength":12664,"gzipLength":4204,"brotliLength":0,"metaUid":"106fae60-6"},"106fae60-9":{"renderedLength":13163,"gzipLength":5058,"brotliLength":0,"metaUid":"106fae60-8"},"106fae60-11":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"106fae60-10"}},"nodeMetas":{"106fae60-0":{"id":"/src/jsx.ts","moduleParts":{"index.js":"106fae60-1"},"imported":[{"uid":"106fae60-12"},{"uid":"106fae60-13"},{"uid":"106fae60-14"},{"uid":"106fae60-15"}],"importedBy":[{"uid":"106fae60-10"}]},"106fae60-2":{"id":"/src/project-scanner.ts","moduleParts":{"index.js":"106fae60-3"},"imported":[{"uid":"106fae60-16"},{"uid":"106fae60-15"}],"importedBy":[{"uid":"106fae60-10"}]},"106fae60-4":{"id":"/src/react-intercept.ts","moduleParts":{"index.js":"106fae60-5"},"imported":[{"uid":"106fae60-17"}],"importedBy":[{"uid":"106fae60-10"}]},"106fae60-6":{"id":"/src/pyreon-intercept.ts","moduleParts":{"index.js":"106fae60-7"},"imported":[{"uid":"106fae60-17"}],"importedBy":[{"uid":"106fae60-10"}]},"106fae60-8":{"id":"/src/test-audit.ts","moduleParts":{"index.js":"106fae60-9"},"imported":[{"uid":"106fae60-16"},{"uid":"106fae60-15"}],"importedBy":[{"uid":"106fae60-10"}]},"106fae60-10":{"id":"/src/index.ts","moduleParts":{"index.js":"106fae60-11"},"imported":[{"uid":"106fae60-0"},{"uid":"106fae60-2"},{"uid":"106fae60-4"},{"uid":"106fae60-6"},{"uid":"106fae60-8"}],"importedBy":[],"isEntry":true},"106fae60-12":{"id":"oxc-parser","moduleParts":{},"imported":[],"importedBy":[{"uid":"106fae60-0"}]},"106fae60-13":{"id":"node:module","moduleParts":{},"imported":[],"importedBy":[{"uid":"106fae60-0"}]},"106fae60-14":{"id":"node:url","moduleParts":{},"imported":[],"importedBy":[{"uid":"106fae60-0"}]},"106fae60-15":{"id":"node:path","moduleParts":{},"imported":[],"importedBy":[{"uid":"106fae60-0"},{"uid":"106fae60-2"},{"uid":"106fae60-8"}]},"106fae60-16":{"id":"node:fs","moduleParts":{},"imported":[],"importedBy":[{"uid":"106fae60-2"},{"uid":"106fae60-8"}]},"106fae60-17":{"id":"typescript","moduleParts":{},"imported":[],"importedBy":[{"uid":"106fae60-4"},{"uid":"106fae60-6"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
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 eventName = (attrName[2] ?? "").toLowerCase() + attrName.slice(3);
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 trimmed = (child.value ?? child.raw ?? "").replace(/\n\s*/g, "").trim();
802
- if (trimmed) out.push({
853
+ const cleaned = cleanJsxText(child.value ?? child.raw ?? "");
854
+ if (cleaned) out.push({
803
855
  kind: "text",
804
- text: trimmed
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 hasNonElem = flatChildren.some((c) => c.kind !== "element");
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 && hasNonElem,
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}`).join("\n");
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, "&amp;").replace(/</g, "&lt;");
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)) detectPropsDestructured(ctx, 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
@@ -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.14.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.123.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
+ }