@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.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +350 -2
- package/lib/types/index.d.ts +71 -1
- package/package.json +5 -5
- package/src/defer-inline.ts +446 -0
- package/src/index.ts +2 -0
- package/src/jsx.ts +47 -2
- package/src/tests/defer-inline.test.ts +199 -0
- package/src/tests/jsx.test.ts +23 -3
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"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
|
|
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
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
60
|
-
"@pyreon/reactivity": "^0.
|
|
61
|
-
"@pyreon/runtime-dom": "^0.
|
|
62
|
-
"@pyreon/test-utils": "^0.13.
|
|
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
|
-
|
|
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
|
+
})
|
package/src/tests/jsx.test.ts
CHANGED
|
@@ -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
|
|
286
|
+
test('spread props on component are wrapped with _wrapSpread to preserve reactivity', () => {
|
|
287
287
|
const result = t('<Comp {...getProps()} label="hi" />')
|
|
288
|
-
// Spread
|
|
289
|
-
|
|
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 ──────────────────────────────────────────────────────
|