@pyreon/compiler 0.16.0 → 0.18.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":"8081644f-1","name":"event-names.ts"},{"uid":"8081644f-3","name":"load-native.ts"},{"uid":"8081644f-5","name":"jsx.ts"},{"uid":"8081644f-7","name":"project-scanner.ts"},{"uid":"8081644f-9","name":"react-intercept.ts"},{"uid":"8081644f-11","name":"pyreon-intercept.ts"},{"uid":"8081644f-13","name":"test-audit.ts"},{"uid":"8081644f-15","name":"island-audit.ts"},{"uid":"8081644f-17","name":"ssg-audit.ts"},{"uid":"8081644f-19","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"8081644f-1":{"renderedLength":2941,"gzipLength":1335,"brotliLength":0,"metaUid":"8081644f-0"},"8081644f-3":{"renderedLength":3959,"gzipLength":1744,"brotliLength":0,"metaUid":"8081644f-2"},"8081644f-5":{"renderedLength":43769,"gzipLength":10484,"brotliLength":0,"metaUid":"8081644f-4"},"8081644f-7":{"renderedLength":4762,"gzipLength":1730,"brotliLength":0,"metaUid":"8081644f-6"},"8081644f-9":{"renderedLength":27698,"gzipLength":6923,"brotliLength":0,"metaUid":"8081644f-8"},"8081644f-11":{"renderedLength":24221,"gzipLength":7766,"brotliLength":0,"metaUid":"8081644f-10"},"8081644f-13":{"renderedLength":13167,"gzipLength":5060,"brotliLength":0,"metaUid":"8081644f-12"},"8081644f-15":{"renderedLength":18208,"gzipLength":6051,"brotliLength":0,"metaUid":"8081644f-14"},"8081644f-17":{"renderedLength":12753,"gzipLength":4173,"brotliLength":0,"metaUid":"8081644f-16"},"8081644f-19":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"8081644f-18"}},"nodeMetas":{"8081644f-0":{"id":"/src/event-names.ts","moduleParts":{"index.js":"8081644f-1"},"imported":[],"importedBy":[{"uid":"8081644f-4"}]},"8081644f-2":{"id":"/src/load-native.ts","moduleParts":{"index.js":"8081644f-3"},"imported":[{"uid":"8081644f-24"},{"uid":"8081644f-25"},{"uid":"8081644f-22"}],"importedBy":[{"uid":"8081644f-4"}]},"8081644f-4":{"id":"/src/jsx.ts","moduleParts":{"index.js":"8081644f-5"},"imported":[{"uid":"8081644f-20"},{"uid":"8081644f-0"},{"uid":"8081644f-2"}],"importedBy":[{"uid":"8081644f-18"}]},"8081644f-6":{"id":"/src/project-scanner.ts","moduleParts":{"index.js":"8081644f-7"},"imported":[{"uid":"8081644f-21"},{"uid":"8081644f-22"}],"importedBy":[{"uid":"8081644f-18"}]},"8081644f-8":{"id":"/src/react-intercept.ts","moduleParts":{"index.js":"8081644f-9"},"imported":[{"uid":"8081644f-23"}],"importedBy":[{"uid":"8081644f-18"}]},"8081644f-10":{"id":"/src/pyreon-intercept.ts","moduleParts":{"index.js":"8081644f-11"},"imported":[{"uid":"8081644f-23"}],"importedBy":[{"uid":"8081644f-18"}]},"8081644f-12":{"id":"/src/test-audit.ts","moduleParts":{"index.js":"8081644f-13"},"imported":[{"uid":"8081644f-21"},{"uid":"8081644f-22"}],"importedBy":[{"uid":"8081644f-18"}]},"8081644f-14":{"id":"/src/island-audit.ts","moduleParts":{"index.js":"8081644f-15"},"imported":[{"uid":"8081644f-21"},{"uid":"8081644f-22"},{"uid":"8081644f-23"}],"importedBy":[{"uid":"8081644f-18"}]},"8081644f-16":{"id":"/src/ssg-audit.ts","moduleParts":{"index.js":"8081644f-17"},"imported":[{"uid":"8081644f-21"},{"uid":"8081644f-22"},{"uid":"8081644f-23"}],"importedBy":[{"uid":"8081644f-18"}]},"8081644f-18":{"id":"/src/index.ts","moduleParts":{"index.js":"8081644f-19"},"imported":[{"uid":"8081644f-4"},{"uid":"8081644f-6"},{"uid":"8081644f-8"},{"uid":"8081644f-10"},{"uid":"8081644f-12"},{"uid":"8081644f-14"},{"uid":"8081644f-16"}],"importedBy":[],"isEntry":true},"8081644f-20":{"id":"oxc-parser","moduleParts":{},"imported":[],"importedBy":[{"uid":"8081644f-4"}]},"8081644f-21":{"id":"node:fs","moduleParts":{},"imported":[],"importedBy":[{"uid":"8081644f-6"},{"uid":"8081644f-12"},{"uid":"8081644f-14"},{"uid":"8081644f-16"}]},"8081644f-22":{"id":"node:path","moduleParts":{},"imported":[],"importedBy":[{"uid":"8081644f-6"},{"uid":"8081644f-12"},{"uid":"8081644f-14"},{"uid":"8081644f-16"},{"uid":"8081644f-2"}]},"8081644f-23":{"id":"typescript","moduleParts":{},"imported":[],"importedBy":[{"uid":"8081644f-8"},{"uid":"8081644f-10"},{"uid":"8081644f-14"},{"uid":"8081644f-16"}]},"8081644f-24":{"id":"node:module","moduleParts":{},"imported":[],"importedBy":[{"uid":"8081644f-2"}]},"8081644f-25":{"id":"node:url","moduleParts":{},"imported":[],"importedBy":[{"uid":"8081644f-2"}]}},"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":"2a48c34b-1","name":"defer-inline.ts"},{"uid":"2a48c34b-3","name":"event-names.ts"},{"uid":"2a48c34b-5","name":"load-native.ts"},{"uid":"2a48c34b-7","name":"jsx.ts"},{"uid":"2a48c34b-9","name":"project-scanner.ts"},{"uid":"2a48c34b-11","name":"react-intercept.ts"},{"uid":"2a48c34b-13","name":"pyreon-intercept.ts"},{"uid":"2a48c34b-15","name":"test-audit.ts"},{"uid":"2a48c34b-17","name":"island-audit.ts"},{"uid":"2a48c34b-19","name":"ssg-audit.ts"},{"uid":"2a48c34b-21","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"2a48c34b-1":{"renderedLength":10244,"gzipLength":3722,"brotliLength":0,"metaUid":"2a48c34b-0"},"2a48c34b-3":{"renderedLength":2941,"gzipLength":1335,"brotliLength":0,"metaUid":"2a48c34b-2"},"2a48c34b-5":{"renderedLength":3959,"gzipLength":1744,"brotliLength":0,"metaUid":"2a48c34b-4"},"2a48c34b-7":{"renderedLength":45561,"gzipLength":11025,"brotliLength":0,"metaUid":"2a48c34b-6"},"2a48c34b-9":{"renderedLength":4762,"gzipLength":1730,"brotliLength":0,"metaUid":"2a48c34b-8"},"2a48c34b-11":{"renderedLength":27698,"gzipLength":6923,"brotliLength":0,"metaUid":"2a48c34b-10"},"2a48c34b-13":{"renderedLength":24221,"gzipLength":7766,"brotliLength":0,"metaUid":"2a48c34b-12"},"2a48c34b-15":{"renderedLength":13167,"gzipLength":5060,"brotliLength":0,"metaUid":"2a48c34b-14"},"2a48c34b-17":{"renderedLength":18208,"gzipLength":6051,"brotliLength":0,"metaUid":"2a48c34b-16"},"2a48c34b-19":{"renderedLength":12753,"gzipLength":4173,"brotliLength":0,"metaUid":"2a48c34b-18"},"2a48c34b-21":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"2a48c34b-20"}},"nodeMetas":{"2a48c34b-0":{"id":"/src/defer-inline.ts","moduleParts":{"index.js":"2a48c34b-1"},"imported":[{"uid":"2a48c34b-22"}],"importedBy":[{"uid":"2a48c34b-20"}]},"2a48c34b-2":{"id":"/src/event-names.ts","moduleParts":{"index.js":"2a48c34b-3"},"imported":[],"importedBy":[{"uid":"2a48c34b-6"}]},"2a48c34b-4":{"id":"/src/load-native.ts","moduleParts":{"index.js":"2a48c34b-5"},"imported":[{"uid":"2a48c34b-26"},{"uid":"2a48c34b-27"},{"uid":"2a48c34b-24"}],"importedBy":[{"uid":"2a48c34b-6"}]},"2a48c34b-6":{"id":"/src/jsx.ts","moduleParts":{"index.js":"2a48c34b-7"},"imported":[{"uid":"2a48c34b-22"},{"uid":"2a48c34b-2"},{"uid":"2a48c34b-4"}],"importedBy":[{"uid":"2a48c34b-20"}]},"2a48c34b-8":{"id":"/src/project-scanner.ts","moduleParts":{"index.js":"2a48c34b-9"},"imported":[{"uid":"2a48c34b-23"},{"uid":"2a48c34b-24"}],"importedBy":[{"uid":"2a48c34b-20"}]},"2a48c34b-10":{"id":"/src/react-intercept.ts","moduleParts":{"index.js":"2a48c34b-11"},"imported":[{"uid":"2a48c34b-25"}],"importedBy":[{"uid":"2a48c34b-20"}]},"2a48c34b-12":{"id":"/src/pyreon-intercept.ts","moduleParts":{"index.js":"2a48c34b-13"},"imported":[{"uid":"2a48c34b-25"}],"importedBy":[{"uid":"2a48c34b-20"}]},"2a48c34b-14":{"id":"/src/test-audit.ts","moduleParts":{"index.js":"2a48c34b-15"},"imported":[{"uid":"2a48c34b-23"},{"uid":"2a48c34b-24"}],"importedBy":[{"uid":"2a48c34b-20"}]},"2a48c34b-16":{"id":"/src/island-audit.ts","moduleParts":{"index.js":"2a48c34b-17"},"imported":[{"uid":"2a48c34b-23"},{"uid":"2a48c34b-24"},{"uid":"2a48c34b-25"}],"importedBy":[{"uid":"2a48c34b-20"}]},"2a48c34b-18":{"id":"/src/ssg-audit.ts","moduleParts":{"index.js":"2a48c34b-19"},"imported":[{"uid":"2a48c34b-23"},{"uid":"2a48c34b-24"},{"uid":"2a48c34b-25"}],"importedBy":[{"uid":"2a48c34b-20"}]},"2a48c34b-20":{"id":"/src/index.ts","moduleParts":{"index.js":"2a48c34b-21"},"imported":[{"uid":"2a48c34b-0"},{"uid":"2a48c34b-6"},{"uid":"2a48c34b-8"},{"uid":"2a48c34b-10"},{"uid":"2a48c34b-12"},{"uid":"2a48c34b-14"},{"uid":"2a48c34b-16"},{"uid":"2a48c34b-18"}],"importedBy":[],"isEntry":true},"2a48c34b-22":{"id":"oxc-parser","moduleParts":{},"imported":[],"importedBy":[{"uid":"2a48c34b-0"},{"uid":"2a48c34b-6"}]},"2a48c34b-23":{"id":"node:fs","moduleParts":{},"imported":[],"importedBy":[{"uid":"2a48c34b-8"},{"uid":"2a48c34b-14"},{"uid":"2a48c34b-16"},{"uid":"2a48c34b-18"}]},"2a48c34b-24":{"id":"node:path","moduleParts":{},"imported":[],"importedBy":[{"uid":"2a48c34b-8"},{"uid":"2a48c34b-14"},{"uid":"2a48c34b-16"},{"uid":"2a48c34b-18"},{"uid":"2a48c34b-4"}]},"2a48c34b-25":{"id":"typescript","moduleParts":{},"imported":[],"importedBy":[{"uid":"2a48c34b-10"},{"uid":"2a48c34b-12"},{"uid":"2a48c34b-16"},{"uid":"2a48c34b-18"}]},"2a48c34b-26":{"id":"node:module","moduleParts":{},"imported":[],"importedBy":[{"uid":"2a48c34b-4"}]},"2a48c34b-27":{"id":"node:url","moduleParts":{},"imported":[],"importedBy":[{"uid":"2a48c34b-4"}]}},"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,312 @@ 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/defer-inline.ts
11
+ /**
12
+ * Inline-children transform for `<Defer>`.
13
+ *
14
+ * Rewrites:
15
+ *
16
+ * import { Modal } from './Modal'
17
+ * <Defer when={open()}><Modal /></Defer>
18
+ *
19
+ * into:
20
+ *
21
+ * <Defer when={open()} chunk={() => import('./Modal').then(m => ({ default: m.Modal }))}>
22
+ * {C => <C />}
23
+ * </Defer>
24
+ *
25
+ * The static `import { Modal } from './Modal'` is removed when `Modal` is
26
+ * referenced ONLY inside the Defer subtree — otherwise Rolldown would
27
+ * bundle the module statically and the dynamic import becomes a no-op.
28
+ *
29
+ * Scope of v1 (this file):
30
+ * - Single Defer element per file (no nested handling — bail otherwise).
31
+ * - Children: exactly ONE JSXElement, self-closing, capitalised name
32
+ * (component reference), no props. Props or multiple children → leave
33
+ * the Defer untransformed (user must use the explicit `chunk` form).
34
+ * - Imports: named OR default. Namespace imports (`import * as Mod`)
35
+ * and destructured-renamed (`{ X as Y }`) not handled in v1.
36
+ * - Triggers (`when={...}`, `on="visible"`, `on="idle"`) pass through.
37
+ * - Other props on `<Defer>` (e.g. `fallback`) pass through.
38
+ *
39
+ * The transform is intentionally conservative — anything unusual leaves
40
+ * the source unchanged + emits a warning. v2 follow-ups can relax these
41
+ * constraints with closure-capture handling, namespace imports, etc.
42
+ *
43
+ * Pipeline: this runs BEFORE `transformJSX()` in the vite plugin. The
44
+ * output is still JSX — `transformJSX` then converts it to `h()` /
45
+ * `_tpl()` calls as usual.
46
+ */
47
+ /**
48
+ * Detect the language for `parseSync`. `oxc-parser` infers from filename
49
+ * by extension — we mirror the same logic for the few extensions we
50
+ * support so the parser is invoked correctly.
51
+ */
52
+ function getLang$1(filename) {
53
+ if (filename.endsWith(".tsx")) return "tsx";
54
+ if (filename.endsWith(".jsx")) return "jsx";
55
+ if (filename.endsWith(".ts")) return "ts";
56
+ return "js";
57
+ }
58
+ /**
59
+ * Returns the JSX tag name as a string when the opening element's name
60
+ * is a simple identifier (the only shape we recognise as a "named JSX
61
+ * element"). Member-expression names (`<obj.X />`) and namespaced names
62
+ * (`<svg:rect />`) return null — the caller treats those as non-matches.
63
+ */
64
+ function getJsxName(node) {
65
+ const open = node.openingElement;
66
+ if (!open) return null;
67
+ const name = open.name;
68
+ if (!name || name.type !== "JSXIdentifier") return null;
69
+ return name.name;
70
+ }
71
+ /**
72
+ * `<Tag />` qualifies as a "bare component reference child" when:
73
+ * - It's a JSXElement (not text, fragment, or expression container).
74
+ * - The opening name is a capitalised JSXIdentifier (component).
75
+ * - It has no attributes (no props passed).
76
+ * - It's self-closing OR has zero non-whitespace children.
77
+ */
78
+ function isBareComponentChild(node) {
79
+ if (node.type !== "JSXElement") return null;
80
+ const tag = getJsxName(node);
81
+ if (!tag || !/^[A-Z]/.test(tag)) return null;
82
+ if ((node.openingElement.attributes ?? []).length > 0) return null;
83
+ const children = node.children ?? [];
84
+ for (const child of children) {
85
+ if (child.type === "JSXText" && /^\s*$/.test(child.value)) continue;
86
+ return null;
87
+ }
88
+ return { name: tag };
89
+ }
90
+ /**
91
+ * Filter whitespace-only JSXText nodes; return remaining children. JSX
92
+ * source like `<Defer>\n <Modal />\n</Defer>` has 3 children at the AST
93
+ * level: text, element, text. The text nodes are formatting noise.
94
+ */
95
+ function nonWhitespaceChildren(node) {
96
+ return (node.children ?? []).filter((c) => !(c.type === "JSXText" && /^\s*$/.test(c.value)));
97
+ }
98
+ function findDeferMatches(program) {
99
+ const matches = [];
100
+ const walk = (node) => {
101
+ if (!node || typeof node !== "object") return;
102
+ if (node.type === "JSXElement" && getJsxName(node) === "Defer") {
103
+ const open = node.openingElement;
104
+ if (!(open.attributes ?? []).some((a) => a.type === "JSXAttribute" && a.name?.type === "JSXIdentifier" && a.name.name === "chunk")) {
105
+ const live = nonWhitespaceChildren(node);
106
+ if (live.length === 1) {
107
+ const childInfo = isBareComponentChild(live[0]);
108
+ if (childInfo) {
109
+ const close = node.closingElement;
110
+ matches.push({
111
+ node,
112
+ child: live[0],
113
+ childName: childInfo.name,
114
+ insertChunkAt: open.end - 1,
115
+ childrenRange: {
116
+ start: open.end,
117
+ end: close?.start ?? node.end
118
+ }
119
+ });
120
+ }
121
+ }
122
+ }
123
+ }
124
+ for (const key in node) {
125
+ if (key === "parent") continue;
126
+ const v = node[key];
127
+ if (Array.isArray(v)) for (const item of v) walk(item);
128
+ else if (v && typeof v === "object" && typeof v.type === "string") walk(v);
129
+ }
130
+ };
131
+ walk(program);
132
+ return matches;
133
+ }
134
+ function findImportFor(program, name) {
135
+ const body = program.body ?? [];
136
+ for (const stmt of body) {
137
+ if (stmt.type !== "ImportDeclaration") continue;
138
+ const specifiers = stmt.specifiers ?? [];
139
+ for (const spec of specifiers) if (spec.type === "ImportDefaultSpecifier") {
140
+ if (spec.local.name === name) return {
141
+ node: stmt,
142
+ source: stmt.source.value,
143
+ kind: "default"
144
+ };
145
+ } else if (spec.type === "ImportSpecifier") {
146
+ const local = spec.local.name;
147
+ const imported = spec.imported?.name;
148
+ if (local === name && imported !== void 0 && imported === local) return {
149
+ node: stmt,
150
+ source: stmt.source.value,
151
+ kind: "named"
152
+ };
153
+ }
154
+ }
155
+ return null;
156
+ }
157
+ /**
158
+ * Count references to `name` outside the given JSXElement subtree. The
159
+ * static import can only be safely removed if the binding is used
160
+ * EXCLUSIVELY inside that subtree.
161
+ */
162
+ function countReferencesOutside(program, name, skipSubtree) {
163
+ let count = 0;
164
+ const skipStart = skipSubtree.start;
165
+ const skipEnd = skipSubtree.end;
166
+ const countInNode = (node) => {
167
+ if (!node || typeof node !== "object") return;
168
+ const ns = node.start;
169
+ const ne = node.end;
170
+ if (typeof ns === "number" && typeof ne === "number" && ns >= skipStart && ne <= skipEnd) return;
171
+ if (node.type === "Identifier" && node.name === name) count++;
172
+ if (node.type === "JSXIdentifier" && node.name === name) count++;
173
+ for (const key in node) {
174
+ if (key === "parent") continue;
175
+ const v = node[key];
176
+ if (Array.isArray(v)) for (const item of v) countInNode(item);
177
+ else if (v && typeof v === "object" && typeof v.type === "string") countInNode(v);
178
+ }
179
+ };
180
+ const body = program.body ?? [];
181
+ for (const stmt of body) {
182
+ if (stmt.type === "ImportDeclaration") continue;
183
+ countInNode(stmt);
184
+ }
185
+ return count;
186
+ }
187
+ /** Build the chunk={...} attribute string for a default or named import. */
188
+ function buildChunkAttr(source, kind, name) {
189
+ if (kind === "default") return ` chunk={() => import('${source}')}`;
190
+ return ` chunk={() => import('${source}').then((__m) => ({ default: __m.${name} }))}`;
191
+ }
192
+ /**
193
+ * Apply edits to the source string. Edits MUST be non-overlapping; we
194
+ * sort by start descending and splice each into the source so earlier
195
+ * positions stay valid as we work backwards.
196
+ */
197
+ function applyEdits(source, edits) {
198
+ const sorted = [...edits].sort((a, b) => b.start - a.start);
199
+ let out = source;
200
+ for (const e of sorted) out = out.slice(0, e.start) + e.replacement + out.slice(e.end);
201
+ return out;
202
+ }
203
+ /**
204
+ * Main entry. Returns the (possibly transformed) source plus the list
205
+ * of warnings for cases the transform deliberately skipped.
206
+ *
207
+ * Bails (returns input unchanged with `changed: false`) when:
208
+ * - No `<Defer>` JSX element appears in the file (fast path).
209
+ * - The file fails to parse (syntax error — let downstream handle).
210
+ * - No `<Defer>` matches the inline-eligible shape.
211
+ *
212
+ * Per-Defer skips with a warning:
213
+ * - Multiple children → user must use render-prop form
214
+ * - Child has props → user must use render-prop form
215
+ * - Child name isn't imported → can't resolve the chunk source
216
+ * - Child binding is used outside the Defer subtree → can't remove
217
+ * the static import (dynamic import would be a no-op via Rolldown's
218
+ * same-module dedup)
219
+ */
220
+ function transformDeferInline(code, filename = "input.tsx") {
221
+ const warnings = [];
222
+ if (!code.includes("Defer")) return {
223
+ code,
224
+ changed: false,
225
+ warnings
226
+ };
227
+ let program;
228
+ try {
229
+ program = parseSync(filename, code, {
230
+ sourceType: "module",
231
+ lang: getLang$1(filename)
232
+ }).program;
233
+ } catch {
234
+ return {
235
+ code,
236
+ changed: false,
237
+ warnings
238
+ };
239
+ }
240
+ const matches = findDeferMatches(program);
241
+ if (matches.length === 0) return {
242
+ code,
243
+ changed: false,
244
+ warnings
245
+ };
246
+ const edits = [];
247
+ let changed = false;
248
+ for (const m of matches) {
249
+ const importInfo = findImportFor(program, m.childName);
250
+ if (!importInfo) {
251
+ const loc = getLoc(code, m.child.start ?? 0);
252
+ warnings.push({
253
+ message: `<Defer>'s inline child <${m.childName} /> isn't imported — can't resolve a chunk source. Use the explicit \`chunk\` prop, or import ${m.childName} from a module.`,
254
+ line: loc.line,
255
+ column: loc.column,
256
+ code: "defer-inline/import-not-found"
257
+ });
258
+ continue;
259
+ }
260
+ if (countReferencesOutside(program, m.childName, m.node) > 0) {
261
+ const loc = getLoc(code, m.node.start ?? 0);
262
+ warnings.push({
263
+ message: `<Defer>'s inline child <${m.childName} /> is also referenced elsewhere in this file. Inline form requires the import to be used exclusively inside this Defer. Use the explicit \`chunk\` prop form to split despite shared usage.`,
264
+ line: loc.line,
265
+ column: loc.column,
266
+ code: "defer-inline/import-used-elsewhere"
267
+ });
268
+ continue;
269
+ }
270
+ edits.push({
271
+ start: m.insertChunkAt,
272
+ end: m.insertChunkAt,
273
+ replacement: buildChunkAttr(importInfo.source, importInfo.kind, m.childName)
274
+ });
275
+ edits.push({
276
+ start: m.childrenRange.start,
277
+ end: m.childrenRange.end,
278
+ replacement: `{(__C) => <__C />}`
279
+ });
280
+ const impStart = importInfo.node.start;
281
+ let impEnd = importInfo.node.end;
282
+ if (code[impEnd] === "\n") impEnd += 1;
283
+ edits.push({
284
+ start: impStart,
285
+ end: impEnd,
286
+ replacement: ""
287
+ });
288
+ changed = true;
289
+ }
290
+ if (!changed) return {
291
+ code,
292
+ changed: false,
293
+ warnings
294
+ };
295
+ return {
296
+ code: applyEdits(code, edits),
297
+ changed: true,
298
+ warnings
299
+ };
300
+ }
301
+ /** Resolve a byte offset into 1-based line + 0-based column. */
302
+ function getLoc(code, offset) {
303
+ let line = 1;
304
+ let lastNl = -1;
305
+ for (let i = 0; i < offset && i < code.length; i++) if (code.charCodeAt(i) === 10) {
306
+ line++;
307
+ lastNl = i;
308
+ }
309
+ return {
310
+ line,
311
+ column: offset - lastNl - 1
312
+ };
313
+ }
314
+
315
+ //#endregion
10
316
  //#region src/event-names.ts
11
317
  /**
12
318
  * React-style → DOM event-name remap.
@@ -333,6 +639,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
333
639
  let hoistIdx = 0;
334
640
  let needsTplImport = false;
335
641
  let needsRpImport = false;
642
+ let needsWrapSpreadImport = false;
336
643
  let needsBindTextImportGlobal = false;
337
644
  let needsBindDirectImportGlobal = false;
338
645
  let needsBindImportGlobal = false;
@@ -392,6 +699,41 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
392
699
  if (jsxTagName(node) !== "For") return;
393
700
  if (!jsxAttrs(node).some((p) => p.type === "JSXAttribute" && p.name?.type === "JSXIdentifier" && p.name.name === "by")) warn(node.openingElement?.name ?? node, `<For> without a "by" prop will use index-based diffing, which is slower and may cause bugs with stateful children. Add by={(item) => item.id} for efficient keyed reconciliation.`, "missing-key-on-for");
394
701
  }
702
+ /**
703
+ * Wrap component-JSX spread arguments with `_wrapSpread(...)` so
704
+ * getter-shaped reactive props survive esbuild's JS-level spread emit.
705
+ *
706
+ * esbuild compiles `<Comp {...source}>` to `jsx(Comp, { ...source })`.
707
+ * The JS spread fires every getter on `source` and stores the resolved
708
+ * values — collapsing compiler-emitted reactive props (`_rp` thunks
709
+ * later converted to getters by `makeReactiveProps`) to static values
710
+ * before the receiving component sees them.
711
+ *
712
+ * `_wrapSpread` replaces getter descriptors with `_rp`-branded thunks,
713
+ * so the JS-level spread carries function values instead. The runtime
714
+ * `makeReactiveProps` step converts them back to getters on the
715
+ * component's props object — preserving the live signal subscription.
716
+ *
717
+ * Lowercase tags (DOM elements) go through the template path's
718
+ * `_applyProps` which already handles spread reactively — no need to
719
+ * wrap there.
720
+ */
721
+ function handleJsxSpreadAttribute(attr, parentElement) {
722
+ const tagName = jsxTagName(parentElement);
723
+ if (!(tagName.length > 0 && tagName.charAt(0) !== tagName.charAt(0).toLowerCase())) return;
724
+ const arg = attr.argument;
725
+ if (!arg) return;
726
+ if (arg.type === "CallExpression" && arg.callee?.type === "Identifier" && arg.callee.name === "_wrapSpread") return;
727
+ const start = arg.start;
728
+ const end = arg.end;
729
+ const sliced = sliceExpr(arg);
730
+ replacements.push({
731
+ start,
732
+ end,
733
+ text: `_wrapSpread(${sliced})`
734
+ });
735
+ needsWrapSpreadImport = true;
736
+ }
395
737
  function handleJsxAttribute(node, parentElement) {
396
738
  const name = node.name?.type === "JSXIdentifier" ? node.name.name : "";
397
739
  if (SKIP_PROPS.has(name) || EVENT_RE.test(name)) return;
@@ -677,6 +1019,7 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
677
1019
  if (!isSelfClosing(node) && tryTemplateEmit(node)) return;
678
1020
  checkForWarnings(node);
679
1021
  for (const attr of jsxAttrs(node)) if (attr.type === "JSXAttribute") handleJsxAttribute(attr, node);
1022
+ else if (attr.type === "JSXSpreadAttribute") handleJsxSpreadAttribute(attr, node);
680
1023
  for (const child of jsxChildren(node)) if (child.type === "JSXExpressionContainer") handleJsxExpression(child);
681
1024
  else walkNode(child);
682
1025
  return;
@@ -717,7 +1060,12 @@ function transformJSX_JS(code, filename = "input.tsx", options = {}) {
717
1060
  const reactivityImports = needsBindImportGlobal ? `\nimport { _bind } from "@pyreon/reactivity";` : "";
718
1061
  output = `import { ${runtimeDomImports.join(", ")} } from "@pyreon/runtime-dom";${reactivityImports}\n` + output;
719
1062
  }
720
- if (needsRpImport) output = `import { _rp } from "@pyreon/core";\n` + output;
1063
+ if (needsRpImport || needsWrapSpreadImport) {
1064
+ const coreImports = [];
1065
+ if (needsRpImport) coreImports.push("_rp");
1066
+ if (needsWrapSpreadImport) coreImports.push("_wrapSpread");
1067
+ output = `import { ${coreImports.join(", ")} } from "@pyreon/core";\n` + output;
1068
+ }
721
1069
  return {
722
1070
  code: output,
723
1071
  usesTemplates: needsTplImport,
@@ -3671,5 +4019,5 @@ function formatSsgAudit(result, _options = {}) {
3671
4019
  }
3672
4020
 
3673
4021
  //#endregion
3674
- export { auditIslands, auditSsg, auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, formatIslandAudit, formatSsgAudit, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, migrateReactCode, transformJSX, transformJSX_JS };
4022
+ export { auditIslands, auditSsg, auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, formatIslandAudit, formatSsgAudit, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, migrateReactCode, transformDeferInline, transformJSX, transformJSX_JS };
3675
4023
  //# sourceMappingURL=index.js.map
@@ -1,3 +1,73 @@
1
+ //#region src/defer-inline.d.ts
2
+ /**
3
+ * Inline-children transform for `<Defer>`.
4
+ *
5
+ * Rewrites:
6
+ *
7
+ * import { Modal } from './Modal'
8
+ * <Defer when={open()}><Modal /></Defer>
9
+ *
10
+ * into:
11
+ *
12
+ * <Defer when={open()} chunk={() => import('./Modal').then(m => ({ default: m.Modal }))}>
13
+ * {C => <C />}
14
+ * </Defer>
15
+ *
16
+ * The static `import { Modal } from './Modal'` is removed when `Modal` is
17
+ * referenced ONLY inside the Defer subtree — otherwise Rolldown would
18
+ * bundle the module statically and the dynamic import becomes a no-op.
19
+ *
20
+ * Scope of v1 (this file):
21
+ * - Single Defer element per file (no nested handling — bail otherwise).
22
+ * - Children: exactly ONE JSXElement, self-closing, capitalised name
23
+ * (component reference), no props. Props or multiple children → leave
24
+ * the Defer untransformed (user must use the explicit `chunk` form).
25
+ * - Imports: named OR default. Namespace imports (`import * as Mod`)
26
+ * and destructured-renamed (`{ X as Y }`) not handled in v1.
27
+ * - Triggers (`when={...}`, `on="visible"`, `on="idle"`) pass through.
28
+ * - Other props on `<Defer>` (e.g. `fallback`) pass through.
29
+ *
30
+ * The transform is intentionally conservative — anything unusual leaves
31
+ * the source unchanged + emits a warning. v2 follow-ups can relax these
32
+ * constraints with closure-capture handling, namespace imports, etc.
33
+ *
34
+ * Pipeline: this runs BEFORE `transformJSX()` in the vite plugin. The
35
+ * output is still JSX — `transformJSX` then converts it to `h()` /
36
+ * `_tpl()` calls as usual.
37
+ */
38
+ interface DeferInlineWarning {
39
+ message: string;
40
+ line: number;
41
+ column: number;
42
+ code: 'defer-inline/multiple-children' | 'defer-inline/non-component-child' | 'defer-inline/child-has-props' | 'defer-inline/import-not-found' | 'defer-inline/import-used-elsewhere' | 'defer-inline/unsupported-import-shape';
43
+ }
44
+ interface DeferInlineResult {
45
+ /** Transformed source — same as input when no transform applied. */
46
+ code: string;
47
+ /** True when at least one Defer JSX element was rewritten. */
48
+ changed: boolean;
49
+ /** Soft warnings for cases the transform deliberately skipped. */
50
+ warnings: DeferInlineWarning[];
51
+ }
52
+ /**
53
+ * Main entry. Returns the (possibly transformed) source plus the list
54
+ * of warnings for cases the transform deliberately skipped.
55
+ *
56
+ * Bails (returns input unchanged with `changed: false`) when:
57
+ * - No `<Defer>` JSX element appears in the file (fast path).
58
+ * - The file fails to parse (syntax error — let downstream handle).
59
+ * - No `<Defer>` matches the inline-eligible shape.
60
+ *
61
+ * Per-Defer skips with a warning:
62
+ * - Multiple children → user must use render-prop form
63
+ * - Child has props → user must use render-prop form
64
+ * - Child name isn't imported → can't resolve the chunk source
65
+ * - Child binding is used outside the Defer subtree → can't remove
66
+ * the static import (dynamic import would be a no-op via Rolldown's
67
+ * same-module dedup)
68
+ */
69
+ declare function transformDeferInline(code: string, filename?: string): DeferInlineResult;
70
+ //#endregion
1
71
  //#region src/jsx.d.ts
2
72
  /**
3
73
  * JSX transform — wraps dynamic JSX expressions in `() =>` so the Pyreon runtime
@@ -387,5 +457,5 @@ interface SsgAuditFormatOptions {
387
457
  }
388
458
  declare function formatSsgAudit(result: SsgAuditResult, _options?: SsgAuditFormatOptions): string;
389
459
  //#endregion
390
- export { type AuditFormatOptions, type AuditRisk, type CompilerWarning, type ComponentInfo, type ErrorDiagnosis, type IslandAuditFormatOptions, type IslandAuditResult, type IslandFinding, type IslandFindingCode, type IslandInfo, type IslandLocation, type MigrationChange, type MigrationResult, type ProjectContext, type PyreonDiagnostic, type PyreonDiagnosticCode, type ReactDiagnostic, type ReactDiagnosticCode, type RouteInfo, type SsgAuditFormatOptions, type SsgAuditResult, type SsgFinding, type SsgFindingCode, type SsgLocation, type TestAuditEntry, type TestAuditResult, type TransformResult, auditIslands, auditSsg, auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, formatIslandAudit, formatSsgAudit, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, migrateReactCode, transformJSX, transformJSX_JS };
460
+ export { type AuditFormatOptions, type AuditRisk, type CompilerWarning, type ComponentInfo, type DeferInlineResult, type DeferInlineWarning, type ErrorDiagnosis, type IslandAuditFormatOptions, type IslandAuditResult, type IslandFinding, type IslandFindingCode, type IslandInfo, type IslandLocation, type MigrationChange, type MigrationResult, type ProjectContext, type PyreonDiagnostic, type PyreonDiagnosticCode, type ReactDiagnostic, type ReactDiagnosticCode, type RouteInfo, type SsgAuditFormatOptions, type SsgAuditResult, type SsgFinding, type SsgFindingCode, type SsgLocation, type TestAuditEntry, type TestAuditResult, type TransformResult, auditIslands, auditSsg, auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, diagnoseError, formatIslandAudit, formatSsgAudit, formatTestAudit, generateContext, hasPyreonPatterns, hasReactPatterns, migrateReactCode, transformDeferInline, transformJSX, transformJSX_JS };
391
461
  //# sourceMappingURL=index2.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/compiler",
3
- "version": "0.16.0",
3
+ "version": "0.18.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": {
@@ -56,10 +56,10 @@
56
56
  "@pyreon/compiler-win32-x64-msvc": "workspace:^"
57
57
  },
58
58
  "devDependencies": {
59
- "@pyreon/core": "^0.16.0",
60
- "@pyreon/reactivity": "^0.16.0",
61
- "@pyreon/runtime-dom": "^0.16.0",
62
- "@pyreon/test-utils": "^0.13.3",
59
+ "@pyreon/core": "^0.18.0",
60
+ "@pyreon/reactivity": "^0.18.0",
61
+ "@pyreon/runtime-dom": "^0.18.0",
62
+ "@pyreon/test-utils": "^0.13.5",
63
63
  "happy-dom": "^20.8.3"
64
64
  },
65
65
  "peerDependencies": {
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Inline-children transform for `<Defer>`.
3
+ *
4
+ * Rewrites:
5
+ *
6
+ * import { Modal } from './Modal'
7
+ * <Defer when={open()}><Modal /></Defer>
8
+ *
9
+ * into:
10
+ *
11
+ * <Defer when={open()} chunk={() => import('./Modal').then(m => ({ default: m.Modal }))}>
12
+ * {C => <C />}
13
+ * </Defer>
14
+ *
15
+ * The static `import { Modal } from './Modal'` is removed when `Modal` is
16
+ * referenced ONLY inside the Defer subtree — otherwise Rolldown would
17
+ * bundle the module statically and the dynamic import becomes a no-op.
18
+ *
19
+ * Scope of v1 (this file):
20
+ * - Single Defer element per file (no nested handling — bail otherwise).
21
+ * - Children: exactly ONE JSXElement, self-closing, capitalised name
22
+ * (component reference), no props. Props or multiple children → leave
23
+ * the Defer untransformed (user must use the explicit `chunk` form).
24
+ * - Imports: named OR default. Namespace imports (`import * as Mod`)
25
+ * and destructured-renamed (`{ X as Y }`) not handled in v1.
26
+ * - Triggers (`when={...}`, `on="visible"`, `on="idle"`) pass through.
27
+ * - Other props on `<Defer>` (e.g. `fallback`) pass through.
28
+ *
29
+ * The transform is intentionally conservative — anything unusual leaves
30
+ * the source unchanged + emits a warning. v2 follow-ups can relax these
31
+ * constraints with closure-capture handling, namespace imports, etc.
32
+ *
33
+ * Pipeline: this runs BEFORE `transformJSX()` in the vite plugin. The
34
+ * output is still JSX — `transformJSX` then converts it to `h()` /
35
+ * `_tpl()` calls as usual.
36
+ */
37
+
38
+ import { parseSync } from 'oxc-parser'
39
+
40
+ export interface DeferInlineWarning {
41
+ message: string
42
+ line: number
43
+ column: number
44
+ code:
45
+ | 'defer-inline/multiple-children'
46
+ | 'defer-inline/non-component-child'
47
+ | 'defer-inline/child-has-props'
48
+ | 'defer-inline/import-not-found'
49
+ | 'defer-inline/import-used-elsewhere'
50
+ | 'defer-inline/unsupported-import-shape'
51
+ }
52
+
53
+ export interface DeferInlineResult {
54
+ /** Transformed source — same as input when no transform applied. */
55
+ code: string
56
+ /** True when at least one Defer JSX element was rewritten. */
57
+ changed: boolean
58
+ /** Soft warnings for cases the transform deliberately skipped. */
59
+ warnings: DeferInlineWarning[]
60
+ }
61
+
62
+ interface Node {
63
+ type: string
64
+ start: number
65
+ end: number
66
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
+ [key: string]: any
68
+ }
69
+
70
+ interface Edit {
71
+ start: number
72
+ end: number
73
+ replacement: string
74
+ }
75
+
76
+ /**
77
+ * Detect the language for `parseSync`. `oxc-parser` infers from filename
78
+ * by extension — we mirror the same logic for the few extensions we
79
+ * support so the parser is invoked correctly.
80
+ */
81
+ function getLang(filename: string): 'ts' | 'tsx' | 'js' | 'jsx' {
82
+ if (filename.endsWith('.tsx')) return 'tsx'
83
+ if (filename.endsWith('.jsx')) return 'jsx'
84
+ if (filename.endsWith('.ts')) return 'ts'
85
+ return 'js'
86
+ }
87
+
88
+ /**
89
+ * Returns the JSX tag name as a string when the opening element's name
90
+ * is a simple identifier (the only shape we recognise as a "named JSX
91
+ * element"). Member-expression names (`<obj.X />`) and namespaced names
92
+ * (`<svg:rect />`) return null — the caller treats those as non-matches.
93
+ */
94
+ function getJsxName(node: Node): string | null {
95
+ const open = node.openingElement as Node | undefined
96
+ if (!open) return null
97
+ const name = open.name as Node | undefined
98
+ if (!name || name.type !== 'JSXIdentifier') return null
99
+ return name.name as string
100
+ }
101
+
102
+ /**
103
+ * `<Tag />` qualifies as a "bare component reference child" when:
104
+ * - It's a JSXElement (not text, fragment, or expression container).
105
+ * - The opening name is a capitalised JSXIdentifier (component).
106
+ * - It has no attributes (no props passed).
107
+ * - It's self-closing OR has zero non-whitespace children.
108
+ */
109
+ function isBareComponentChild(node: Node): { name: string } | null {
110
+ if (node.type !== 'JSXElement') return null
111
+ const tag = getJsxName(node)
112
+ if (!tag || !/^[A-Z]/.test(tag)) return null
113
+ const open = node.openingElement as Node
114
+ const attrs = (open.attributes as Node[] | undefined) ?? []
115
+ if (attrs.length > 0) return null
116
+ const children = (node.children as Node[] | undefined) ?? []
117
+ for (const child of children) {
118
+ if (child.type === 'JSXText' && /^\s*$/.test(child.value as string)) continue
119
+ return null
120
+ }
121
+ return { name: tag }
122
+ }
123
+
124
+ /**
125
+ * Filter whitespace-only JSXText nodes; return remaining children. JSX
126
+ * source like `<Defer>\n <Modal />\n</Defer>` has 3 children at the AST
127
+ * level: text, element, text. The text nodes are formatting noise.
128
+ */
129
+ function nonWhitespaceChildren(node: Node): Node[] {
130
+ const children = (node.children as Node[] | undefined) ?? []
131
+ return children.filter(
132
+ (c) => !(c.type === 'JSXText' && /^\s*$/.test(c.value as string)),
133
+ )
134
+ }
135
+
136
+ /**
137
+ * `<Defer chunk={...} ...>` qualifies for the inline transform when:
138
+ * - The opening name is `Defer`.
139
+ * - No attribute named `chunk` (otherwise user is using the explicit form).
140
+ * - Exactly ONE non-whitespace child that is a bare component reference.
141
+ */
142
+ interface DeferMatch {
143
+ /** The <Defer> JSXElement node. */
144
+ node: Node
145
+ /** The single child component element. */
146
+ child: Node
147
+ /** Component identifier name (e.g. 'Modal'). */
148
+ childName: string
149
+ /** Position where to insert the `chunk` attribute (just after `<Defer`). */
150
+ insertChunkAt: number
151
+ /** Range covering the child JSX element + surrounding whitespace inside Defer's open/close tags. */
152
+ childrenRange: { start: number; end: number }
153
+ }
154
+
155
+ function findDeferMatches(program: Node): DeferMatch[] {
156
+ const matches: DeferMatch[] = []
157
+
158
+ const walk = (node: Node | null | undefined): void => {
159
+ if (!node || typeof node !== 'object') return
160
+
161
+ if (node.type === 'JSXElement' && getJsxName(node) === 'Defer') {
162
+ const open = node.openingElement as Node
163
+ const attrs = (open.attributes as Node[] | undefined) ?? []
164
+ const hasChunk = attrs.some(
165
+ (a) =>
166
+ a.type === 'JSXAttribute' &&
167
+ (a.name as Node | undefined)?.type === 'JSXIdentifier' &&
168
+ (a.name as Node).name === 'chunk',
169
+ )
170
+ if (!hasChunk) {
171
+ const live = nonWhitespaceChildren(node)
172
+ if (live.length === 1) {
173
+ const childInfo = isBareComponentChild(live[0]!)
174
+ if (childInfo) {
175
+ const close = node.closingElement as Node | undefined
176
+ matches.push({
177
+ node,
178
+ child: live[0]!,
179
+ childName: childInfo.name,
180
+ // Insert chunk attribute right after the opening tag name.
181
+ // `<Defer when={x}>` — we want to insert just before the `>`
182
+ // (or `/>` if self-closing, though Defer is never self-closing
183
+ // when it has inline children). Use the closing `>` of the
184
+ // opening tag — that's `open.end - 1` for `<Defer ...>` form.
185
+ insertChunkAt: (open.end as number) - 1,
186
+ childrenRange: {
187
+ start: open.end as number,
188
+ end: (close?.start as number) ?? (node.end as number),
189
+ },
190
+ })
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ // Recurse — JSX children, prop expressions, statements, etc.
197
+ for (const key in node) {
198
+ if (key === 'parent') continue
199
+ const v = node[key]
200
+ if (Array.isArray(v)) {
201
+ for (const item of v) walk(item as Node)
202
+ } else if (v && typeof v === 'object' && typeof (v as Node).type === 'string') {
203
+ walk(v as Node)
204
+ }
205
+ }
206
+ }
207
+
208
+ walk(program)
209
+ return matches
210
+ }
211
+
212
+ /**
213
+ * Find ImportDeclarations matching a target identifier and classify them.
214
+ * Returns null when the binding can't be resolved or the import shape
215
+ * isn't one we handle (namespace, renamed destructure).
216
+ */
217
+ interface ImportInfo {
218
+ /** The `ImportDeclaration` AST node. */
219
+ node: Node
220
+ /** The module source string (without quotes). */
221
+ source: string
222
+ /** 'default' or 'named' — controls how the rewrite resolves the chunk. */
223
+ kind: 'default' | 'named'
224
+ }
225
+
226
+ function findImportFor(program: Node, name: string): ImportInfo | null {
227
+ const body = (program.body as Node[] | undefined) ?? []
228
+ for (const stmt of body) {
229
+ if (stmt.type !== 'ImportDeclaration') continue
230
+ const specifiers = (stmt.specifiers as Node[] | undefined) ?? []
231
+ for (const spec of specifiers) {
232
+ if (spec.type === 'ImportDefaultSpecifier') {
233
+ const local = (spec.local as Node).name as string
234
+ if (local === name) {
235
+ return {
236
+ node: stmt,
237
+ source: (stmt.source as Node).value as string,
238
+ kind: 'default',
239
+ }
240
+ }
241
+ } else if (spec.type === 'ImportSpecifier') {
242
+ const local = (spec.local as Node).name as string
243
+ const imported = (spec.imported as Node | undefined)?.name as string | undefined
244
+ // Only handle the un-renamed case: `import { Modal } from ...`.
245
+ // `{ Modal as M }` — skip (would need to know the original export
246
+ // name for the chunk-resolution path; v1 bails).
247
+ if (local === name && imported !== undefined && imported === local) {
248
+ return {
249
+ node: stmt,
250
+ source: (stmt.source as Node).value as string,
251
+ kind: 'named',
252
+ }
253
+ }
254
+ }
255
+ // ImportNamespaceSpecifier (`import * as M`) — not handled in v1.
256
+ }
257
+ }
258
+ return null
259
+ }
260
+
261
+ /**
262
+ * Count references to `name` outside the given JSXElement subtree. The
263
+ * static import can only be safely removed if the binding is used
264
+ * EXCLUSIVELY inside that subtree.
265
+ */
266
+ function countReferencesOutside(program: Node, name: string, skipSubtree: Node): number {
267
+ let count = 0
268
+ const skipStart = skipSubtree.start as number
269
+ const skipEnd = skipSubtree.end as number
270
+
271
+ // Walk every statement except ImportDeclarations (we don't want the
272
+ // import specifier itself to count as a usage). Within each statement
273
+ // walk recursively, skipping any subtree whose byte range falls
274
+ // entirely inside the Defer being rewritten.
275
+ const countInNode = (node: Node): void => {
276
+ if (!node || typeof node !== 'object') return
277
+ const ns = node.start as number | undefined
278
+ const ne = node.end as number | undefined
279
+ if (typeof ns === 'number' && typeof ne === 'number' && ns >= skipStart && ne <= skipEnd) {
280
+ return
281
+ }
282
+ if (node.type === 'Identifier' && (node.name as string) === name) count++
283
+ if (node.type === 'JSXIdentifier' && (node.name as string) === name) count++
284
+ for (const key in node) {
285
+ if (key === 'parent') continue
286
+ const v = node[key]
287
+ if (Array.isArray(v)) {
288
+ for (const item of v) countInNode(item as Node)
289
+ } else if (v && typeof v === 'object' && typeof (v as Node).type === 'string') {
290
+ countInNode(v as Node)
291
+ }
292
+ }
293
+ }
294
+ const body = (program.body as Node[] | undefined) ?? []
295
+ for (const stmt of body) {
296
+ if (stmt.type === 'ImportDeclaration') continue
297
+ countInNode(stmt)
298
+ }
299
+ return count
300
+ }
301
+
302
+ /** Build the chunk={...} attribute string for a default or named import. */
303
+ function buildChunkAttr(source: string, kind: 'default' | 'named', name: string): string {
304
+ if (kind === 'default') {
305
+ return ` chunk={() => import('${source}')}`
306
+ }
307
+ // Named: re-wrap so the chunk's `default` is the named export.
308
+ return ` chunk={() => import('${source}').then((__m) => ({ default: __m.${name} }))}`
309
+ }
310
+
311
+ /**
312
+ * Apply edits to the source string. Edits MUST be non-overlapping; we
313
+ * sort by start descending and splice each into the source so earlier
314
+ * positions stay valid as we work backwards.
315
+ */
316
+ function applyEdits(source: string, edits: Edit[]): string {
317
+ const sorted = [...edits].sort((a, b) => b.start - a.start)
318
+ let out = source
319
+ for (const e of sorted) {
320
+ out = out.slice(0, e.start) + e.replacement + out.slice(e.end)
321
+ }
322
+ return out
323
+ }
324
+
325
+ /**
326
+ * Main entry. Returns the (possibly transformed) source plus the list
327
+ * of warnings for cases the transform deliberately skipped.
328
+ *
329
+ * Bails (returns input unchanged with `changed: false`) when:
330
+ * - No `<Defer>` JSX element appears in the file (fast path).
331
+ * - The file fails to parse (syntax error — let downstream handle).
332
+ * - No `<Defer>` matches the inline-eligible shape.
333
+ *
334
+ * Per-Defer skips with a warning:
335
+ * - Multiple children → user must use render-prop form
336
+ * - Child has props → user must use render-prop form
337
+ * - Child name isn't imported → can't resolve the chunk source
338
+ * - Child binding is used outside the Defer subtree → can't remove
339
+ * the static import (dynamic import would be a no-op via Rolldown's
340
+ * same-module dedup)
341
+ */
342
+ export function transformDeferInline(
343
+ code: string,
344
+ filename = 'input.tsx',
345
+ ): DeferInlineResult {
346
+ const warnings: DeferInlineWarning[] = []
347
+
348
+ // Fast path — skip the parse entirely when the file has no Defer mention.
349
+ if (!code.includes('Defer')) {
350
+ return { code, changed: false, warnings }
351
+ }
352
+
353
+ let program: Node
354
+ try {
355
+ const result = parseSync(filename, code, {
356
+ sourceType: 'module',
357
+ lang: getLang(filename),
358
+ })
359
+ program = result.program as Node
360
+ } catch {
361
+ // Parse failure — leave to the downstream transformJSX which reports
362
+ // its own diagnostics.
363
+ return { code, changed: false, warnings }
364
+ }
365
+
366
+ const matches = findDeferMatches(program)
367
+ if (matches.length === 0) return { code, changed: false, warnings }
368
+
369
+ const edits: Edit[] = []
370
+ let changed = false
371
+
372
+ for (const m of matches) {
373
+ const importInfo = findImportFor(program, m.childName)
374
+ if (!importInfo) {
375
+ const loc = getLoc(code, (m.child.start as number) ?? 0)
376
+ warnings.push({
377
+ message: `<Defer>'s inline child <${m.childName} /> isn't imported — can't resolve a chunk source. Use the explicit \`chunk\` prop, or import ${m.childName} from a module.`,
378
+ line: loc.line,
379
+ column: loc.column,
380
+ code: 'defer-inline/import-not-found',
381
+ })
382
+ continue
383
+ }
384
+
385
+ const outsideUses = countReferencesOutside(program, m.childName, m.node)
386
+ if (outsideUses > 0) {
387
+ const loc = getLoc(code, (m.node.start as number) ?? 0)
388
+ warnings.push({
389
+ message: `<Defer>'s inline child <${m.childName} /> is also referenced elsewhere in this file. Inline form requires the import to be used exclusively inside this Defer. Use the explicit \`chunk\` prop form to split despite shared usage.`,
390
+ line: loc.line,
391
+ column: loc.column,
392
+ code: 'defer-inline/import-used-elsewhere',
393
+ })
394
+ continue
395
+ }
396
+
397
+ // 1. Insert chunk attribute just before the opening tag's `>`.
398
+ edits.push({
399
+ start: m.insertChunkAt,
400
+ end: m.insertChunkAt,
401
+ replacement: buildChunkAttr(importInfo.source, importInfo.kind, m.childName),
402
+ })
403
+
404
+ // 2. Replace the children (the bare `<Modal />`) with a render-prop
405
+ // that invokes the loaded component. Preserve surrounding
406
+ // whitespace by replacing only the JSX text region inside Defer's
407
+ // open/close tags. Use a non-letter identifier for the render-prop
408
+ // binding (`__C`) to avoid clashing with anything in scope.
409
+ edits.push({
410
+ start: m.childrenRange.start,
411
+ end: m.childrenRange.end,
412
+ replacement: `{(__C) => <__C />}`,
413
+ })
414
+
415
+ // 3. Remove the static import. Replace the entire ImportDeclaration
416
+ // range with an empty string. Includes the trailing newline if
417
+ // present so we don't leave a blank line.
418
+ const impStart = importInfo.node.start as number
419
+ let impEnd = importInfo.node.end as number
420
+ if (code[impEnd] === '\n') impEnd += 1
421
+ edits.push({
422
+ start: impStart,
423
+ end: impEnd,
424
+ replacement: '',
425
+ })
426
+
427
+ changed = true
428
+ }
429
+
430
+ if (!changed) return { code, changed: false, warnings }
431
+
432
+ return { code: applyEdits(code, edits), changed: true, warnings }
433
+ }
434
+
435
+ /** Resolve a byte offset into 1-based line + 0-based column. */
436
+ function getLoc(code: string, offset: number): { line: number; column: number } {
437
+ let line = 1
438
+ let lastNl = -1
439
+ for (let i = 0; i < offset && i < code.length; i++) {
440
+ if (code.charCodeAt(i) === 10 /* \n */) {
441
+ line++
442
+ lastNl = i
443
+ }
444
+ }
445
+ return { line, column: offset - lastNl - 1 }
446
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  // @pyreon/compiler — JSX reactive transform for Pyreon
2
2
 
3
+ export type { DeferInlineResult, DeferInlineWarning } from './defer-inline'
4
+ export { transformDeferInline } from './defer-inline'
3
5
  export type { CompilerWarning, TransformResult } from './jsx'
4
6
  export { transformJSX, transformJSX_JS } from './jsx'
5
7
  export type { ComponentInfo, IslandInfo, ProjectContext, RouteInfo } from './project-scanner'
package/src/jsx.ts CHANGED
@@ -273,6 +273,7 @@ export function transformJSX_JS(
273
273
  let hoistIdx = 0
274
274
  let needsTplImport = false
275
275
  let needsRpImport = false
276
+ let needsWrapSpreadImport = false
276
277
  let needsBindTextImportGlobal = false
277
278
  let needsBindDirectImportGlobal = false
278
279
  let needsBindImportGlobal = false
@@ -344,6 +345,46 @@ export function transformJSX_JS(
344
345
  }
345
346
  }
346
347
 
348
+ /**
349
+ * Wrap component-JSX spread arguments with `_wrapSpread(...)` so
350
+ * getter-shaped reactive props survive esbuild's JS-level spread emit.
351
+ *
352
+ * esbuild compiles `<Comp {...source}>` to `jsx(Comp, { ...source })`.
353
+ * The JS spread fires every getter on `source` and stores the resolved
354
+ * values — collapsing compiler-emitted reactive props (`_rp` thunks
355
+ * later converted to getters by `makeReactiveProps`) to static values
356
+ * before the receiving component sees them.
357
+ *
358
+ * `_wrapSpread` replaces getter descriptors with `_rp`-branded thunks,
359
+ * so the JS-level spread carries function values instead. The runtime
360
+ * `makeReactiveProps` step converts them back to getters on the
361
+ * component's props object — preserving the live signal subscription.
362
+ *
363
+ * Lowercase tags (DOM elements) go through the template path's
364
+ * `_applyProps` which already handles spread reactively — no need to
365
+ * wrap there.
366
+ */
367
+ function handleJsxSpreadAttribute(attr: N, parentElement: N): void {
368
+ const tagName = jsxTagName(parentElement)
369
+ const isComponent =
370
+ tagName.length > 0 && tagName.charAt(0) !== tagName.charAt(0).toLowerCase()
371
+ if (!isComponent) return
372
+ const arg = attr.argument
373
+ if (!arg) return
374
+ // Skip already-wrapped sources (idempotent compilation guard).
375
+ if (
376
+ arg.type === 'CallExpression' &&
377
+ arg.callee?.type === 'Identifier' &&
378
+ arg.callee.name === '_wrapSpread'
379
+ )
380
+ return
381
+ const start = arg.start as number
382
+ const end = arg.end as number
383
+ const sliced = sliceExpr(arg)
384
+ replacements.push({ start, end, text: `_wrapSpread(${sliced})` })
385
+ needsWrapSpreadImport = true
386
+ }
387
+
347
388
  function handleJsxAttribute(node: N, parentElement: N): void {
348
389
  const name = node.name?.type === 'JSXIdentifier' ? node.name.name : ''
349
390
  if (SKIP_PROPS.has(name) || EVENT_RE.test(name)) return
@@ -733,6 +774,7 @@ export function transformJSX_JS(
733
774
  checkForWarnings(node)
734
775
  for (const attr of jsxAttrs(node)) {
735
776
  if (attr.type === 'JSXAttribute') handleJsxAttribute(attr, node)
777
+ else if (attr.type === 'JSXSpreadAttribute') handleJsxSpreadAttribute(attr, node)
736
778
  }
737
779
  for (const child of jsxChildren(node)) {
738
780
  if (child.type === 'JSXExpressionContainer') handleJsxExpression(child)
@@ -793,8 +835,11 @@ export function transformJSX_JS(
793
835
  output
794
836
  }
795
837
 
796
- if (needsRpImport) {
797
- output = `import { _rp } from "@pyreon/core";\n` + output
838
+ if (needsRpImport || needsWrapSpreadImport) {
839
+ const coreImports: string[] = []
840
+ if (needsRpImport) coreImports.push('_rp')
841
+ if (needsWrapSpreadImport) coreImports.push('_wrapSpread')
842
+ output = `import { ${coreImports.join(', ')} } from "@pyreon/core";\n` + output
798
843
  }
799
844
 
800
845
  return { code: output, usesTemplates: needsTplImport, warnings }
@@ -0,0 +1,199 @@
1
+ import { transformDeferInline } from '../defer-inline'
2
+
3
+ describe('transformDeferInline — basic rewrites', () => {
4
+ test('rewrites <Defer when={x}><Modal /></Defer> with named import', () => {
5
+ const input = `
6
+ import { Defer } from '@pyreon/core'
7
+ import { Modal } from './Modal'
8
+
9
+ export function App() {
10
+ const open = () => true
11
+ return <Defer when={open}><Modal /></Defer>
12
+ }
13
+ `
14
+ const result = transformDeferInline(input, 'app.tsx')
15
+ expect(result.changed).toBe(true)
16
+ expect(result.code).not.toContain("import { Modal } from './Modal'")
17
+ expect(result.code).toContain(`chunk={() => import('./Modal').then((__m) => ({ default: __m.Modal }))}`)
18
+ expect(result.code).toContain('{(__C) => <__C />}')
19
+ })
20
+
21
+ test('rewrites with default import', () => {
22
+ const input = `
23
+ import { Defer } from '@pyreon/core'
24
+ import Modal from './Modal'
25
+
26
+ export function App() {
27
+ return <Defer when={() => true}><Modal /></Defer>
28
+ }
29
+ `
30
+ const result = transformDeferInline(input, 'app.tsx')
31
+ expect(result.changed).toBe(true)
32
+ expect(result.code).not.toContain('import Modal from')
33
+ expect(result.code).toContain(`chunk={() => import('./Modal')}`)
34
+ expect(result.code).not.toContain(`.then((__m) =>`)
35
+ })
36
+
37
+ test('preserves other props on Defer (fallback, when, on)', () => {
38
+ const input = `
39
+ import { Defer } from '@pyreon/core'
40
+ import { Modal } from './Modal'
41
+ export function App() {
42
+ return <Defer when={() => true} fallback={<span>loading</span>}><Modal /></Defer>
43
+ }
44
+ `
45
+ const result = transformDeferInline(input, 'app.tsx')
46
+ expect(result.changed).toBe(true)
47
+ expect(result.code).toContain('when={() => true}')
48
+ expect(result.code).toContain('fallback={<span>loading</span>}')
49
+ })
50
+
51
+ test('works for on="visible" trigger', () => {
52
+ const input = `
53
+ import { Defer } from '@pyreon/core'
54
+ import { Comments } from './Comments'
55
+ export function Post() {
56
+ return <Defer on="visible"><Comments /></Defer>
57
+ }
58
+ `
59
+ const result = transformDeferInline(input, 'post.tsx')
60
+ expect(result.changed).toBe(true)
61
+ expect(result.code).toContain('on="visible"')
62
+ expect(result.code).toContain(`chunk={() => import('./Comments').then((__m) => ({ default: __m.Comments }))}`)
63
+ })
64
+ })
65
+
66
+ describe('transformDeferInline — bail-out cases', () => {
67
+ test('leaves unchanged when chunk prop is already provided', () => {
68
+ const input = `
69
+ import { Defer } from '@pyreon/core'
70
+ import { Modal } from './Modal'
71
+ export function App() {
72
+ return (
73
+ <Defer chunk={() => import('./Modal')} when={() => true}>
74
+ {Modal => <Modal />}
75
+ </Defer>
76
+ )
77
+ }
78
+ `
79
+ const result = transformDeferInline(input, 'app.tsx')
80
+ expect(result.changed).toBe(false)
81
+ expect(result.code).toBe(input)
82
+ expect(result.warnings).toEqual([])
83
+ })
84
+
85
+ test('warns when inline child is also used outside the Defer', () => {
86
+ const input = `
87
+ import { Defer } from '@pyreon/core'
88
+ import { Modal } from './Modal'
89
+ const eagerCopy = <Modal />
90
+ export function App() {
91
+ return <Defer when={() => true}><Modal /></Defer>
92
+ }
93
+ `
94
+ const result = transformDeferInline(input, 'app.tsx')
95
+ expect(result.changed).toBe(false)
96
+ expect(result.warnings).toHaveLength(1)
97
+ expect(result.warnings[0]!.code).toBe('defer-inline/import-used-elsewhere')
98
+ })
99
+
100
+ test('warns when inline child is not imported', () => {
101
+ const input = `
102
+ import { Defer } from '@pyreon/core'
103
+ export function App() {
104
+ return <Defer when={() => true}><LocalThing /></Defer>
105
+ }
106
+ function LocalThing() { return null }
107
+ `
108
+ const result = transformDeferInline(input, 'app.tsx')
109
+ expect(result.changed).toBe(false)
110
+ expect(result.warnings).toHaveLength(1)
111
+ expect(result.warnings[0]!.code).toBe('defer-inline/import-not-found')
112
+ })
113
+
114
+ test('skips Defer with multiple children (still requires render-prop form)', () => {
115
+ const input = `
116
+ import { Defer } from '@pyreon/core'
117
+ import { Modal } from './Modal'
118
+ import { Spinner } from './Spinner'
119
+ export function App() {
120
+ return <Defer when={() => true}><Modal /><Spinner /></Defer>
121
+ }
122
+ `
123
+ const result = transformDeferInline(input, 'app.tsx')
124
+ // No transform fires (multi-child shape doesn't match the inline-eligible
125
+ // single-component-child pattern). No warning either — v1 just leaves it
126
+ // alone; downstream Defer's runtime behaviour handles the malformed shape.
127
+ expect(result.changed).toBe(false)
128
+ })
129
+
130
+ test('skips Defer whose child has props (multi-prop closure capture)', () => {
131
+ const input = `
132
+ import { Defer } from '@pyreon/core'
133
+ import { Modal } from './Modal'
134
+ export function App() {
135
+ return <Defer when={() => true}><Modal title="hi" /></Defer>
136
+ }
137
+ `
138
+ const result = transformDeferInline(input, 'app.tsx')
139
+ expect(result.changed).toBe(false)
140
+ })
141
+
142
+ test('fast-path: no Defer in source returns unchanged', () => {
143
+ const input = `
144
+ import { signal } from '@pyreon/reactivity'
145
+ export const count = signal(0)
146
+ `
147
+ const result = transformDeferInline(input, 'count.ts')
148
+ expect(result.changed).toBe(false)
149
+ expect(result.code).toBe(input)
150
+ })
151
+
152
+ test('does not blow up on syntactically-invalid source — returns unchanged', () => {
153
+ const input = `import {{{ Defer broken syntax`
154
+ const result = transformDeferInline(input, 'broken.tsx')
155
+ expect(result.changed).toBe(false)
156
+ // Returns the input unchanged; downstream parser will surface the real error.
157
+ expect(result.code).toBe(input)
158
+ })
159
+
160
+ test('skips renamed imports — { Modal as M } not handled in v1', () => {
161
+ const input = `
162
+ import { Defer } from '@pyreon/core'
163
+ import { Modal as M } from './Modal'
164
+ export function App() {
165
+ return <Defer when={() => true}><M /></Defer>
166
+ }
167
+ `
168
+ const result = transformDeferInline(input, 'app.tsx')
169
+ expect(result.changed).toBe(false)
170
+ // Renamed-import case is not yet supported — falls through to the
171
+ // import-not-found warning (no specifier whose `local.name === 'M'`
172
+ // AND `imported.name === local.name` matches).
173
+ expect(result.warnings[0]?.code).toBe('defer-inline/import-not-found')
174
+ })
175
+ })
176
+
177
+ describe('transformDeferInline — multiple Defers in one file', () => {
178
+ test('rewrites two independent Defers with distinct imports', () => {
179
+ const input = `
180
+ import { Defer } from '@pyreon/core'
181
+ import { Modal } from './Modal'
182
+ import { Comments } from './Comments'
183
+ export function App() {
184
+ return (
185
+ <div>
186
+ <Defer when={() => true}><Modal /></Defer>
187
+ <Defer on="visible"><Comments /></Defer>
188
+ </div>
189
+ )
190
+ }
191
+ `
192
+ const result = transformDeferInline(input, 'app.tsx')
193
+ expect(result.changed).toBe(true)
194
+ expect(result.code).not.toContain("import { Modal } from './Modal'")
195
+ expect(result.code).not.toContain("import { Comments } from './Comments'")
196
+ expect(result.code).toContain(`chunk={() => import('./Modal').then((__m) => ({ default: __m.Modal }))}`)
197
+ expect(result.code).toContain(`chunk={() => import('./Comments').then((__m) => ({ default: __m.Comments }))}`)
198
+ })
199
+ })
@@ -283,13 +283,33 @@ describe('JSX transform — component elements', () => {
283
283
  expect(result).toContain('_rp(')
284
284
  })
285
285
 
286
- test('spread props on component pass through without _rp wrapping', () => {
286
+ test('spread props on component are wrapped with _wrapSpread to preserve reactivity', () => {
287
287
  const result = t('<Comp {...getProps()} label="hi" />')
288
- // Spread should remain as-is
289
- expect(result).toContain('{...getProps()}')
288
+ // Spread argument is wrapped so getter-shaped reactive props survive
289
+ // esbuild's JS-level object spread in the automatic JSX runtime.
290
+ expect(result).toContain('{..._wrapSpread(getProps())}')
290
291
  // Static label should not be wrapped
291
292
  expect(result).not.toContain('_rp(() => "hi")')
292
293
  })
294
+
295
+ test('spread props on DOM elements are NOT wrapped (handled by template path)', () => {
296
+ const result = t('<div {...rest} class="x" />')
297
+ // DOM-element spreads go through the template path's _applyProps.
298
+ expect(result).toContain('{...rest}')
299
+ expect(result).not.toContain('_wrapSpread')
300
+ })
301
+
302
+ test('multiple spread sources on a component each get wrapped independently', () => {
303
+ const result = t('<Comp {...a} {...b} foo="x" />')
304
+ expect(result).toContain('{..._wrapSpread(a)}')
305
+ expect(result).toContain('{..._wrapSpread(b)}')
306
+ })
307
+
308
+ test('_wrapSpread emission is idempotent on re-compilation', () => {
309
+ const result = t('<Comp {..._wrapSpread(rest)} />')
310
+ // Should not double-wrap.
311
+ expect(result).not.toContain('_wrapSpread(_wrapSpread(')
312
+ })
293
313
  })
294
314
 
295
315
  // ─── Spread attributes ──────────────────────────────────────────────────────