sinwan-compiler 0.3.1 → 0.4.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/dist/{chunk-fadpxq9v.js → chunk-sy6qf73w.js} +505 -39
- package/dist/cli.js +1 -1
- package/dist/exports.d.ts +5 -4
- package/dist/index.js +13 -2
- package/dist/reactive-wrap.d.ts +2 -0
- package/dist/transform.d.ts +1 -0
- package/docs/api.md +1 -1
- package/docs/architecture.md +2 -0
- package/docs/plugins.md +9 -8
- package/docs/transform.md +322 -21
- package/package.json +7 -7
|
@@ -3,16 +3,18 @@
|
|
|
3
3
|
import * as path3 from "path";
|
|
4
4
|
|
|
5
5
|
// src/analyze.ts
|
|
6
|
-
import { parse } from "@babel/parser";
|
|
6
|
+
import { parse as parse2 } from "@babel/parser";
|
|
7
7
|
import * as t3 from "@babel/types";
|
|
8
8
|
import _traverse3 from "@babel/traverse";
|
|
9
|
-
import * as
|
|
9
|
+
import * as fs2 from "fs";
|
|
10
10
|
import * as path2 from "path";
|
|
11
11
|
|
|
12
12
|
// src/reactive-wrap.ts
|
|
13
13
|
import * as t from "@babel/types";
|
|
14
14
|
import _traverse from "@babel/traverse";
|
|
15
15
|
import * as path from "path";
|
|
16
|
+
import { parse } from "@babel/parser";
|
|
17
|
+
import * as fs from "fs";
|
|
16
18
|
var traverse = typeof _traverse === "function" ? _traverse : _traverse.default ?? _traverse;
|
|
17
19
|
var REACTIVE_SOURCE_MODULES = {
|
|
18
20
|
"sinwan/store": new Set(["createMutable", "createStore"]),
|
|
@@ -87,6 +89,282 @@ function trackReactiveImports2(ast) {
|
|
|
87
89
|
});
|
|
88
90
|
return names;
|
|
89
91
|
}
|
|
92
|
+
var pureImportCache = new Map;
|
|
93
|
+
var PURE_EXTENSIONS = [
|
|
94
|
+
".ts",
|
|
95
|
+
".tsx",
|
|
96
|
+
".js",
|
|
97
|
+
".jsx",
|
|
98
|
+
".mjs",
|
|
99
|
+
".cjs",
|
|
100
|
+
".mts",
|
|
101
|
+
".cts"
|
|
102
|
+
];
|
|
103
|
+
function tryResolveFile(base) {
|
|
104
|
+
if (PURE_EXTENSIONS.some((ext) => base.endsWith(ext))) {
|
|
105
|
+
try {
|
|
106
|
+
if (fs.existsSync(base))
|
|
107
|
+
return base;
|
|
108
|
+
} catch {}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
for (const ext of PURE_EXTENSIONS) {
|
|
112
|
+
try {
|
|
113
|
+
if (fs.existsSync(base + ext))
|
|
114
|
+
return base + ext;
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
function tryResolveIndex(dir) {
|
|
120
|
+
for (const ext of PURE_EXTENSIONS) {
|
|
121
|
+
try {
|
|
122
|
+
const candidate = path.join(dir, "index") + ext;
|
|
123
|
+
if (fs.existsSync(candidate))
|
|
124
|
+
return candidate;
|
|
125
|
+
} catch {}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
function tryResolveRelative(source, fromFile) {
|
|
130
|
+
const base = path.resolve(path.dirname(fromFile), source);
|
|
131
|
+
return tryResolveFile(base) ?? tryResolveIndex(base);
|
|
132
|
+
}
|
|
133
|
+
function tryResolveBare(source, fromFile) {
|
|
134
|
+
const parts = source.split("/");
|
|
135
|
+
const pkgName = source.startsWith("@") ? `${parts[0]}/${parts[1]}` : parts[0];
|
|
136
|
+
const subpath = parts.slice(source.startsWith("@") ? 2 : 1).join("/");
|
|
137
|
+
let dir = path.dirname(fromFile);
|
|
138
|
+
for (let i = 0;i < 30; i++) {
|
|
139
|
+
const nodeModules = path.join(dir, "node_modules", pkgName);
|
|
140
|
+
if (fs.existsSync(nodeModules)) {
|
|
141
|
+
return resolvePackageEntry(nodeModules, subpath);
|
|
142
|
+
}
|
|
143
|
+
const parent = path.dirname(dir);
|
|
144
|
+
if (parent === dir)
|
|
145
|
+
break;
|
|
146
|
+
dir = parent;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
function resolvePackageEntry(pkgDir, subpath) {
|
|
151
|
+
let pkgJson;
|
|
152
|
+
try {
|
|
153
|
+
pkgJson = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8"));
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
if (typeof pkgJson.exports === "object" && pkgJson.exports) {
|
|
158
|
+
const exports = pkgJson.exports;
|
|
159
|
+
const exportKey = subpath ? `./${subpath}` : ".";
|
|
160
|
+
const entry = exports[exportKey];
|
|
161
|
+
if (typeof entry === "string") {
|
|
162
|
+
const resolved = tryResolveFile(path.resolve(pkgDir, entry));
|
|
163
|
+
if (resolved)
|
|
164
|
+
return resolved;
|
|
165
|
+
} else if (entry && typeof entry === "object") {
|
|
166
|
+
for (const condValue of Object.values(entry)) {
|
|
167
|
+
if (typeof condValue === "string") {
|
|
168
|
+
const resolved = tryResolveFile(path.resolve(pkgDir, condValue));
|
|
169
|
+
if (resolved)
|
|
170
|
+
return resolved;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (!subpath) {
|
|
176
|
+
for (const field of ["source", "module", "main", "types"]) {
|
|
177
|
+
const entry = pkgJson[field];
|
|
178
|
+
if (typeof entry === "string") {
|
|
179
|
+
const resolved = tryResolveFile(path.resolve(pkgDir, entry));
|
|
180
|
+
if (resolved)
|
|
181
|
+
return resolved;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const indexResolved = tryResolveIndex(pkgDir);
|
|
185
|
+
if (indexResolved)
|
|
186
|
+
return indexResolved;
|
|
187
|
+
}
|
|
188
|
+
if (subpath) {
|
|
189
|
+
for (const srcDir of ["src", "dist", "."]) {
|
|
190
|
+
const base = path.join(pkgDir, srcDir, subpath);
|
|
191
|
+
const resolved = tryResolveFile(base) ?? tryResolveIndex(base);
|
|
192
|
+
if (resolved)
|
|
193
|
+
return resolved;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
function resolveAnyImport(source, fromFile, resolveImport) {
|
|
199
|
+
if (resolveImport) {
|
|
200
|
+
try {
|
|
201
|
+
const resolved = resolveImport(source, fromFile);
|
|
202
|
+
if (resolved)
|
|
203
|
+
return resolved;
|
|
204
|
+
} catch {}
|
|
205
|
+
}
|
|
206
|
+
if (source.startsWith(".")) {
|
|
207
|
+
return tryResolveRelative(source, fromFile);
|
|
208
|
+
}
|
|
209
|
+
return tryResolveBare(source, fromFile);
|
|
210
|
+
}
|
|
211
|
+
function scanPureExports(filePath, resolveImport, depth = 0) {
|
|
212
|
+
const pure = new Map;
|
|
213
|
+
if (depth > 10)
|
|
214
|
+
return pure;
|
|
215
|
+
let code;
|
|
216
|
+
try {
|
|
217
|
+
code = fs.readFileSync(filePath, "utf-8");
|
|
218
|
+
} catch {
|
|
219
|
+
return pure;
|
|
220
|
+
}
|
|
221
|
+
let ast;
|
|
222
|
+
try {
|
|
223
|
+
ast = parse(code, {
|
|
224
|
+
sourceType: "module",
|
|
225
|
+
plugins: ["jsx", "typescript"],
|
|
226
|
+
sourceFilename: filePath
|
|
227
|
+
});
|
|
228
|
+
} catch {
|
|
229
|
+
return pure;
|
|
230
|
+
}
|
|
231
|
+
const hasPureJSDoc = (node) => node?.leadingComments?.some((c) => c.type === "CommentBlock" && c.value.includes("@sinwan-pure")) ?? false;
|
|
232
|
+
traverse(ast, {
|
|
233
|
+
ExportNamedDeclaration(p) {
|
|
234
|
+
if (hasPureJSDoc(p.node)) {
|
|
235
|
+
const decl = p.node.declaration;
|
|
236
|
+
if (t.isFunctionDeclaration(decl) && decl.id) {
|
|
237
|
+
pure.set(decl.id.name, "function");
|
|
238
|
+
} else if (t.isClassDeclaration(decl) && decl.id) {
|
|
239
|
+
pure.set(decl.id.name, "class");
|
|
240
|
+
} else if (t.isVariableDeclaration(decl)) {
|
|
241
|
+
for (const d of decl.declarations) {
|
|
242
|
+
if (!t.isIdentifier(d.id))
|
|
243
|
+
continue;
|
|
244
|
+
const init = d.init;
|
|
245
|
+
if (t.isObjectExpression(init)) {
|
|
246
|
+
pure.set(d.id.name, "object");
|
|
247
|
+
} else {
|
|
248
|
+
pure.set(d.id.name, "function");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const source = p.node.source?.value;
|
|
254
|
+
if (source && p.node.specifiers) {
|
|
255
|
+
const resolved = resolveAnyImport(source, filePath, resolveImport);
|
|
256
|
+
if (resolved) {
|
|
257
|
+
let reExportPure = pureImportCache.get(resolved);
|
|
258
|
+
if (!reExportPure) {
|
|
259
|
+
reExportPure = scanPureExports(resolved, resolveImport, depth + 1);
|
|
260
|
+
pureImportCache.set(resolved, reExportPure);
|
|
261
|
+
}
|
|
262
|
+
for (const spec of p.node.specifiers) {
|
|
263
|
+
if (!t.isExportSpecifier(spec))
|
|
264
|
+
continue;
|
|
265
|
+
const exported = t.isIdentifier(spec.exported) ? spec.exported.name : spec.exported.value;
|
|
266
|
+
const original = spec.local.name;
|
|
267
|
+
const kind = reExportPure.get(original);
|
|
268
|
+
if (kind) {
|
|
269
|
+
pure.set(exported, kind);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
ExportDefaultDeclaration(p) {
|
|
276
|
+
if (!hasPureJSDoc(p.node))
|
|
277
|
+
return;
|
|
278
|
+
const decl = p.node.declaration;
|
|
279
|
+
if (t.isFunctionDeclaration(decl) || t.isArrowFunctionExpression(decl) || t.isFunctionExpression(decl)) {
|
|
280
|
+
pure.set("default", "function");
|
|
281
|
+
} else if (t.isClassDeclaration(decl)) {
|
|
282
|
+
pure.set("default", "class");
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
ExportAllDeclaration(p) {
|
|
286
|
+
const source = p.node.source?.value;
|
|
287
|
+
if (!source)
|
|
288
|
+
return;
|
|
289
|
+
const resolved = resolveAnyImport(source, filePath, resolveImport);
|
|
290
|
+
if (!resolved)
|
|
291
|
+
return;
|
|
292
|
+
let reExportPure = pureImportCache.get(resolved);
|
|
293
|
+
if (!reExportPure) {
|
|
294
|
+
reExportPure = scanPureExports(resolved, resolveImport, depth + 1);
|
|
295
|
+
pureImportCache.set(resolved, reExportPure);
|
|
296
|
+
}
|
|
297
|
+
for (const [name, kind] of reExportPure) {
|
|
298
|
+
if (name !== "default") {
|
|
299
|
+
pure.set(name, kind);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
return pure;
|
|
305
|
+
}
|
|
306
|
+
function collectPureImports(ast, filename, resolveImport) {
|
|
307
|
+
const result = {
|
|
308
|
+
direct: new Set,
|
|
309
|
+
namespace: new Map,
|
|
310
|
+
member: new Set
|
|
311
|
+
};
|
|
312
|
+
if (!filename)
|
|
313
|
+
return result;
|
|
314
|
+
const absoluteFilename = path.resolve(filename);
|
|
315
|
+
traverse(ast, {
|
|
316
|
+
ImportDeclaration(p) {
|
|
317
|
+
const source = p.node.source.value;
|
|
318
|
+
if (p.node.importKind === "type")
|
|
319
|
+
return;
|
|
320
|
+
const resolved = resolveAnyImport(source, absoluteFilename, resolveImport);
|
|
321
|
+
if (!resolved)
|
|
322
|
+
return;
|
|
323
|
+
let filePure = pureImportCache.get(resolved);
|
|
324
|
+
if (!filePure) {
|
|
325
|
+
filePure = scanPureExports(resolved, resolveImport);
|
|
326
|
+
pureImportCache.set(resolved, filePure);
|
|
327
|
+
}
|
|
328
|
+
for (const spec of p.node.specifiers) {
|
|
329
|
+
if (t.isImportSpecifier(spec)) {
|
|
330
|
+
const imported = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
|
|
331
|
+
const kind = filePure.get(imported);
|
|
332
|
+
if (!kind)
|
|
333
|
+
continue;
|
|
334
|
+
if (kind === "function") {
|
|
335
|
+
result.direct.add(spec.local.name);
|
|
336
|
+
} else {
|
|
337
|
+
result.member.add(spec.local.name);
|
|
338
|
+
}
|
|
339
|
+
} else if (t.isImportDefaultSpecifier(spec)) {
|
|
340
|
+
const kind = filePure.get("default");
|
|
341
|
+
if (!kind)
|
|
342
|
+
continue;
|
|
343
|
+
if (kind === "function") {
|
|
344
|
+
result.direct.add(spec.local.name);
|
|
345
|
+
} else {
|
|
346
|
+
result.member.add(spec.local.name);
|
|
347
|
+
}
|
|
348
|
+
} else if (t.isImportNamespaceSpecifier(spec)) {
|
|
349
|
+
const pureMembers = new Set;
|
|
350
|
+
for (const [name, kind] of filePure) {
|
|
351
|
+
if (name === "default")
|
|
352
|
+
continue;
|
|
353
|
+
if (kind === "function") {
|
|
354
|
+
pureMembers.add(name);
|
|
355
|
+
} else {
|
|
356
|
+
pureMembers.add(name);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (pureMembers.size > 0) {
|
|
360
|
+
result.namespace.set(spec.local.name, pureMembers);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
90
368
|
function isReactiveSourceCall(expr, names) {
|
|
91
369
|
if (!t.isCallExpression(expr))
|
|
92
370
|
return null;
|
|
@@ -136,8 +414,16 @@ function isUseFetchCall(expr, names) {
|
|
|
136
414
|
}
|
|
137
415
|
return false;
|
|
138
416
|
}
|
|
139
|
-
function trackLocalScopeBindings(path, names, scope) {
|
|
140
|
-
|
|
417
|
+
function trackLocalScopeBindings(path, names, scope, shallow = false) {
|
|
418
|
+
const collect = (visitors) => {
|
|
419
|
+
path.traverse(shallow ? {
|
|
420
|
+
...visitors,
|
|
421
|
+
Function(p) {
|
|
422
|
+
p.skip();
|
|
423
|
+
}
|
|
424
|
+
} : visitors);
|
|
425
|
+
};
|
|
426
|
+
collect({
|
|
141
427
|
VariableDeclarator(p) {
|
|
142
428
|
const id = p.node.id;
|
|
143
429
|
const init = p.node.init;
|
|
@@ -187,7 +473,7 @@ function trackLocalScopeBindings(path, names, scope) {
|
|
|
187
473
|
}
|
|
188
474
|
}
|
|
189
475
|
});
|
|
190
|
-
|
|
476
|
+
collect({
|
|
191
477
|
VariableDeclarator(p) {
|
|
192
478
|
const id = p.node.id;
|
|
193
479
|
const init = p.node.init;
|
|
@@ -213,7 +499,7 @@ function trackLocalScopeBindings(path, names, scope) {
|
|
|
213
499
|
}
|
|
214
500
|
}
|
|
215
501
|
});
|
|
216
|
-
|
|
502
|
+
collect({
|
|
217
503
|
"FunctionDeclaration|VariableDeclarator"(p) {
|
|
218
504
|
const node = p.node;
|
|
219
505
|
let fn = null;
|
|
@@ -924,7 +1210,184 @@ function propagateReactiveProps2(localScopes, callGraph, componentFunctions) {
|
|
|
924
1210
|
}
|
|
925
1211
|
return reactiveProps;
|
|
926
1212
|
}
|
|
1213
|
+
function hasSnapshotPragma(node) {
|
|
1214
|
+
return node.leadingComments?.some((comment) => /\bsinwan-snapshot\b/.test(comment.value)) ?? false;
|
|
1215
|
+
}
|
|
1216
|
+
function canRewriteDerivedReference(ref) {
|
|
1217
|
+
if (!ref.isIdentifier() || !ref.isReferencedIdentifier() || ref.findParent((p) => p.isTSType())) {
|
|
1218
|
+
return false;
|
|
1219
|
+
}
|
|
1220
|
+
let target = ref;
|
|
1221
|
+
while ((target.parentPath?.isMemberExpression() || target.parentPath?.isOptionalMemberExpression()) && target.key === "object" || (target.parentPath?.isTSNonNullExpression() || target.parentPath?.isTSAsExpression() || target.parentPath?.isTSTypeAssertion()) && target.key === "expression") {
|
|
1222
|
+
target = target.parentPath;
|
|
1223
|
+
}
|
|
1224
|
+
const parent = target.parentPath;
|
|
1225
|
+
return !(parent?.isAssignmentExpression() && target.key === "left" || parent?.isUpdateExpression() || parent?.isUnaryExpression({ operator: "delete" }) || (parent?.isForInStatement() || parent?.isForOfStatement()) && target.key === "left" || target !== ref && (parent?.isCallExpression() || parent?.isOptionalCallExpression()) && target.key === "callee");
|
|
1226
|
+
}
|
|
1227
|
+
function isPureDerivedCall(path, names, pureImports) {
|
|
1228
|
+
if (!path.isCallExpression() && !path.isOptionalCallExpression())
|
|
1229
|
+
return false;
|
|
1230
|
+
const callee = path.node.callee;
|
|
1231
|
+
if (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) {
|
|
1232
|
+
const props = [];
|
|
1233
|
+
let node = callee;
|
|
1234
|
+
while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
|
|
1235
|
+
const prop = node.property;
|
|
1236
|
+
if (t.isIdentifier(prop)) {
|
|
1237
|
+
props.unshift(prop.name);
|
|
1238
|
+
} else if (t.isStringLiteral(prop)) {
|
|
1239
|
+
props.unshift(prop.value);
|
|
1240
|
+
} else {
|
|
1241
|
+
return false;
|
|
1242
|
+
}
|
|
1243
|
+
node = node.object;
|
|
1244
|
+
}
|
|
1245
|
+
if (!t.isIdentifier(node))
|
|
1246
|
+
return false;
|
|
1247
|
+
const root = node.name;
|
|
1248
|
+
if (pureImports.member.has(root)) {
|
|
1249
|
+
return true;
|
|
1250
|
+
}
|
|
1251
|
+
const nsMembers = pureImports.namespace.get(root);
|
|
1252
|
+
if (nsMembers && props.some((p) => nsMembers.has(p))) {
|
|
1253
|
+
return true;
|
|
1254
|
+
}
|
|
1255
|
+
return false;
|
|
1256
|
+
}
|
|
1257
|
+
if (!t.isIdentifier(callee))
|
|
1258
|
+
return false;
|
|
1259
|
+
const binding = path.scope.getBinding(callee.name);
|
|
1260
|
+
if (!binding) {
|
|
1261
|
+
return ["Boolean", "Number", "String"].includes(callee.name) && path.node.arguments.length === 1;
|
|
1262
|
+
}
|
|
1263
|
+
if ((binding.path.isImportSpecifier() || binding.path.isImportDefaultSpecifier()) && pureImports.direct.has(callee.name)) {
|
|
1264
|
+
return true;
|
|
1265
|
+
}
|
|
1266
|
+
if (path.node.arguments.length !== 0 || !binding.path.isVariableDeclarator())
|
|
1267
|
+
return false;
|
|
1268
|
+
const { id, init } = binding.path.node;
|
|
1269
|
+
return t.isArrayPattern(id) && id.elements[0] === binding.identifier && t.isCallExpression(init) && t.isIdentifier(init.callee) && names.useState.has(init.callee.name) && binding.path.scope.getBinding(init.callee.name)?.path.isImportSpecifier() === true;
|
|
1270
|
+
}
|
|
1271
|
+
function isDerivedInitializer(declaration, scope, names, derived, pureImports) {
|
|
1272
|
+
const init = declaration.get("init");
|
|
1273
|
+
init.assertExpression();
|
|
1274
|
+
let safe = true;
|
|
1275
|
+
let reactive = false;
|
|
1276
|
+
const inspect = (path) => {
|
|
1277
|
+
if (!safe || path.isTSType()) {
|
|
1278
|
+
path.skip();
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
const node = path.node;
|
|
1282
|
+
if (path.isReferencedIdentifier()) {
|
|
1283
|
+
const binding = path.scope.getBinding(path.node.name);
|
|
1284
|
+
if (binding && (!binding.constant || binding.kind === "let" || binding.kind === "var" || binding.path.isVariableDeclarator() && (binding.path.node.start ?? -1) >= (declaration.node.start ?? Infinity))) {
|
|
1285
|
+
safe = false;
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
if (binding && derived.has(binding))
|
|
1289
|
+
reactive = true;
|
|
1290
|
+
if (isReactiveRead(node, scope))
|
|
1291
|
+
reactive = true;
|
|
1292
|
+
} else if (path.isMemberExpression() || path.isOptionalMemberExpression()) {
|
|
1293
|
+
if (isReactiveRead(node, scope))
|
|
1294
|
+
reactive = true;
|
|
1295
|
+
} else if (path.isCallExpression() || path.isOptionalCallExpression()) {
|
|
1296
|
+
if (!isPureDerivedCall(path, names, pureImports))
|
|
1297
|
+
safe = false;
|
|
1298
|
+
if (isGetterCall(path.node, scope))
|
|
1299
|
+
reactive = true;
|
|
1300
|
+
} else if (path.isSpreadElement()) {} else if (!(path.isIdentifier() || path.isLiteral() || path.isTemplateLiteral() || path.isTemplateElement() || path.isBinaryExpression() || path.isLogicalExpression() || path.isConditionalExpression() || path.isObjectExpression() || path.isObjectProperty() || path.isArrayExpression() || path.isUnaryExpression() && path.node.operator !== "delete" || path.isTSAsExpression() || path.isTSSatisfiesExpression() || path.isTSNonNullExpression() || path.isTSTypeAssertion())) {
|
|
1301
|
+
safe = false;
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
inspect(init);
|
|
1305
|
+
if (safe)
|
|
1306
|
+
init.traverse({ enter: inspect });
|
|
1307
|
+
return safe && reactive;
|
|
1308
|
+
}
|
|
1309
|
+
function promoteReactiveConstInitializers(ast, options) {
|
|
1310
|
+
if (!options.derivedLocals && !options.dev)
|
|
1311
|
+
return;
|
|
1312
|
+
const names = trackReactiveImports2(ast);
|
|
1313
|
+
const pureImports = collectPureImports(ast, options.filename, options.resolveImport);
|
|
1314
|
+
const { functions } = collectComponentFunctions2(ast, names.cc);
|
|
1315
|
+
traverse(ast, {
|
|
1316
|
+
Program(p) {
|
|
1317
|
+
p.scope.crawl();
|
|
1318
|
+
}
|
|
1319
|
+
});
|
|
1320
|
+
traverse(ast, {
|
|
1321
|
+
Function(fnPath) {
|
|
1322
|
+
if (!functions.has(fnPath.node))
|
|
1323
|
+
return;
|
|
1324
|
+
const factory = fnPath.parentPath;
|
|
1325
|
+
if (!factory.isCallExpression() || !t.isIdentifier(factory.node.callee) || factory.scope.getBinding(factory.node.callee.name)?.kind !== "module")
|
|
1326
|
+
return;
|
|
1327
|
+
const body = fnPath.get("body");
|
|
1328
|
+
if (!body.isBlockStatement())
|
|
1329
|
+
return;
|
|
1330
|
+
let hasDirectEval = false;
|
|
1331
|
+
fnPath.traverse({
|
|
1332
|
+
CallExpression(p) {
|
|
1333
|
+
if (t.isIdentifier(p.node.callee, { name: "eval" }) && !p.scope.getBinding("eval"))
|
|
1334
|
+
hasDirectEval = true;
|
|
1335
|
+
}
|
|
1336
|
+
});
|
|
1337
|
+
if (hasDirectEval)
|
|
1338
|
+
return;
|
|
1339
|
+
const scope = { bindings: new Map };
|
|
1340
|
+
trackPropBindings(scope, fnPath.node.params[0], getAllPropNames2(fnPath.node.params[0]));
|
|
1341
|
+
trackLocalScopeBindings(fnPath, names, scope, true);
|
|
1342
|
+
const derived = new Map;
|
|
1343
|
+
for (const statement of body.get("body")) {
|
|
1344
|
+
if (!statement.isVariableDeclaration({ kind: "const" }) || hasSnapshotPragma(statement.node))
|
|
1345
|
+
continue;
|
|
1346
|
+
for (const declaration of statement.get("declarations")) {
|
|
1347
|
+
const { id, init } = declaration.node;
|
|
1348
|
+
if (!t.isIdentifier(id) || !init || hasSnapshotPragma(declaration.node) || hasSnapshotPragma(init))
|
|
1349
|
+
continue;
|
|
1350
|
+
const binding = declaration.scope.getBinding(id.name);
|
|
1351
|
+
if (!binding.constant || !binding.referenced || !binding.referencePaths.every(canRewriteDerivedReference))
|
|
1352
|
+
continue;
|
|
1353
|
+
if (isDerivedInitializer(declaration, scope, names, derived, pureImports)) {
|
|
1354
|
+
derived.set(binding, { declaration, binding });
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
for (const { declaration, binding } of derived.values()) {
|
|
1359
|
+
const { id, init } = declaration.node;
|
|
1360
|
+
if (!t.isIdentifier(id) || !t.isExpression(init))
|
|
1361
|
+
continue;
|
|
1362
|
+
const location = `${options.filename ?? "<module>"}:${id.loc?.start.line ?? 0}`;
|
|
1363
|
+
if (!options.derivedLocals) {
|
|
1364
|
+
if (binding.referencePaths.some((ref) => ref.findParent((p) => p.isJSXExpressionContainer()))) {
|
|
1365
|
+
console.warn(`[Sinwan] ${location}: derived local "${id.name}" snapshots a reactive read used in JSX. Enable derivedLocals or use a getter/computed; use /* sinwan-snapshot */ for an intentional snapshot.`);
|
|
1366
|
+
}
|
|
1367
|
+
continue;
|
|
1368
|
+
}
|
|
1369
|
+
const getter = t.arrowFunctionExpression([], init);
|
|
1370
|
+
if (t.isTSTypeAnnotation(id.typeAnnotation)) {
|
|
1371
|
+
getter.returnType = id.typeAnnotation;
|
|
1372
|
+
id.typeAnnotation = null;
|
|
1373
|
+
}
|
|
1374
|
+
declaration.node.init = getter;
|
|
1375
|
+
for (const ref of binding.referencePaths) {
|
|
1376
|
+
if (options.dev && ref.parentPath?.isIfStatement() && ref.key === "test" && ref.getFunctionParent()?.node === fnPath.node) {
|
|
1377
|
+
console.warn(`[Sinwan] ${location}: derived local "${id.name}" in a setup if is read once, not a reactive branch. The compiler calls its getter to preserve boolean semantics; use Show for reactive control flow.`);
|
|
1378
|
+
}
|
|
1379
|
+
if (ref.parentPath?.isObjectProperty({ shorthand: true })) {
|
|
1380
|
+
ref.parentPath.node.shorthand = false;
|
|
1381
|
+
}
|
|
1382
|
+
ref.replaceWith(t.callExpression(t.identifier(id.name), []));
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
fnPath.scope.crawl();
|
|
1386
|
+
}
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
927
1389
|
function wrapReactiveExpressions2(ast, options = {}) {
|
|
1390
|
+
promoteReactiveConstInitializers(ast, options);
|
|
928
1391
|
let metadata = null;
|
|
929
1392
|
if (options.analyzeMetadata) {
|
|
930
1393
|
metadata = options.analyzeMetadata;
|
|
@@ -1054,7 +1517,10 @@ function wrapReactiveExpressions2(ast, options = {}) {
|
|
|
1054
1517
|
},
|
|
1055
1518
|
Function(p) {
|
|
1056
1519
|
const scope = { bindings: new Map };
|
|
1057
|
-
|
|
1520
|
+
if (options.derivedLocals && componentFunctions.has(p.node)) {
|
|
1521
|
+
trackPropBindings(scope, p.node.params[0], getAllPropNames2(p.node.params[0]));
|
|
1522
|
+
}
|
|
1523
|
+
trackLocalScopeBindings(p, names, scope, options.derivedLocals === true && componentFunctions.has(p.node));
|
|
1058
1524
|
localScopes.set(p.node, scope);
|
|
1059
1525
|
},
|
|
1060
1526
|
JSXElement(p) {
|
|
@@ -1441,11 +1907,11 @@ function serializeModule(mod) {
|
|
|
1441
1907
|
function restoreModule(filePath, serialized) {
|
|
1442
1908
|
let code;
|
|
1443
1909
|
try {
|
|
1444
|
-
code =
|
|
1910
|
+
code = fs2.readFileSync(filePath, "utf-8");
|
|
1445
1911
|
} catch {
|
|
1446
1912
|
return null;
|
|
1447
1913
|
}
|
|
1448
|
-
const ast =
|
|
1914
|
+
const ast = parse2(code, {
|
|
1449
1915
|
sourceType: "module",
|
|
1450
1916
|
plugins: ["jsx", "typescript"]
|
|
1451
1917
|
});
|
|
@@ -1622,7 +2088,7 @@ function collectImportedCallSites(ast, componentNames, imports) {
|
|
|
1622
2088
|
return sites;
|
|
1623
2089
|
}
|
|
1624
2090
|
function analyzeModule2(code, filePath) {
|
|
1625
|
-
const ast =
|
|
2091
|
+
const ast = parse2(code, {
|
|
1626
2092
|
sourceType: "module",
|
|
1627
2093
|
plugins: ["jsx", "typescript"]
|
|
1628
2094
|
});
|
|
@@ -1675,7 +2141,7 @@ function analyzeModule2(code, filePath) {
|
|
|
1675
2141
|
}
|
|
1676
2142
|
function loadTsConfigPaths(tsConfigPath) {
|
|
1677
2143
|
try {
|
|
1678
|
-
const raw = JSON.parse(
|
|
2144
|
+
const raw = JSON.parse(fs2.readFileSync(tsConfigPath, "utf-8"));
|
|
1679
2145
|
const paths = raw.compilerOptions?.paths;
|
|
1680
2146
|
if (!paths)
|
|
1681
2147
|
return null;
|
|
@@ -1718,7 +2184,7 @@ function resolveRelativeImport(source, fromFile, extensions) {
|
|
|
1718
2184
|
...extensions.map((ext) => path2.join(base, "index" + ext))
|
|
1719
2185
|
];
|
|
1720
2186
|
for (const candidate of candidates) {
|
|
1721
|
-
if (
|
|
2187
|
+
if (fs2.existsSync(candidate))
|
|
1722
2188
|
return candidate;
|
|
1723
2189
|
}
|
|
1724
2190
|
return null;
|
|
@@ -1763,7 +2229,7 @@ function parseAliasObject(content, aliases) {
|
|
|
1763
2229
|
}
|
|
1764
2230
|
function loadBunfigAliases(bunfigPath) {
|
|
1765
2231
|
try {
|
|
1766
|
-
const content =
|
|
2232
|
+
const content = fs2.readFileSync(bunfigPath, "utf-8");
|
|
1767
2233
|
const aliases = new Map;
|
|
1768
2234
|
const lines = content.split(`
|
|
1769
2235
|
`);
|
|
@@ -1816,14 +2282,14 @@ function resolveWithExtensions(mapped, extensions) {
|
|
|
1816
2282
|
...extensions.map((ext) => path2.join(mapped, "index" + ext))
|
|
1817
2283
|
];
|
|
1818
2284
|
for (const candidate of candidates) {
|
|
1819
|
-
if (
|
|
2285
|
+
if (fs2.existsSync(candidate))
|
|
1820
2286
|
return candidate;
|
|
1821
2287
|
}
|
|
1822
2288
|
return null;
|
|
1823
2289
|
}
|
|
1824
2290
|
function readPackageName(pkgDir) {
|
|
1825
2291
|
try {
|
|
1826
|
-
const pkgJson = JSON.parse(
|
|
2292
|
+
const pkgJson = JSON.parse(fs2.readFileSync(path2.join(pkgDir, "package.json"), "utf-8"));
|
|
1827
2293
|
return pkgJson.name ?? null;
|
|
1828
2294
|
} catch {
|
|
1829
2295
|
return null;
|
|
@@ -1852,7 +2318,7 @@ function resolveWorkspacePackage(source, packages, extensions) {
|
|
|
1852
2318
|
const subpath = source.slice(matchedName.length + 1);
|
|
1853
2319
|
let sourceDir;
|
|
1854
2320
|
try {
|
|
1855
|
-
const pkgJson = JSON.parse(
|
|
2321
|
+
const pkgJson = JSON.parse(fs2.readFileSync(path2.join(matchedPkg, "package.json"), "utf-8"));
|
|
1856
2322
|
if (pkgJson.source) {
|
|
1857
2323
|
sourceDir = path2.dirname(path2.resolve(matchedPkg, pkgJson.source));
|
|
1858
2324
|
} else {
|
|
@@ -1866,7 +2332,7 @@ function resolveWorkspacePackage(source, packages, extensions) {
|
|
|
1866
2332
|
}
|
|
1867
2333
|
function loadPackageJsonWorkspaces(filePath) {
|
|
1868
2334
|
try {
|
|
1869
|
-
const pkg = JSON.parse(
|
|
2335
|
+
const pkg = JSON.parse(fs2.readFileSync(filePath, "utf-8"));
|
|
1870
2336
|
return pkg.workspaces ?? [];
|
|
1871
2337
|
} catch {
|
|
1872
2338
|
return [];
|
|
@@ -1874,7 +2340,7 @@ function loadPackageJsonWorkspaces(filePath) {
|
|
|
1874
2340
|
}
|
|
1875
2341
|
function loadPnpmWorkspaces(filePath) {
|
|
1876
2342
|
try {
|
|
1877
|
-
const content =
|
|
2343
|
+
const content = fs2.readFileSync(filePath, "utf-8");
|
|
1878
2344
|
const lines = content.split(`
|
|
1879
2345
|
`);
|
|
1880
2346
|
const patterns = [];
|
|
@@ -1907,14 +2373,14 @@ function expandGlob(pattern, cwd) {
|
|
|
1907
2373
|
const parts = fullPattern.split(path2.sep);
|
|
1908
2374
|
const wildcardIndex = parts.findIndex((p) => p.includes("*"));
|
|
1909
2375
|
if (wildcardIndex === -1) {
|
|
1910
|
-
return
|
|
2376
|
+
return fs2.existsSync(fullPattern) ? [fullPattern] : [];
|
|
1911
2377
|
}
|
|
1912
2378
|
const prefix = parts.slice(0, wildcardIndex).join(path2.sep);
|
|
1913
2379
|
const suffixParts = parts.slice(wildcardIndex + 1);
|
|
1914
2380
|
const suffix = suffixParts.join(path2.sep);
|
|
1915
2381
|
let entries;
|
|
1916
2382
|
try {
|
|
1917
|
-
entries =
|
|
2383
|
+
entries = fs2.readdirSync(prefix, { withFileTypes: true });
|
|
1918
2384
|
} catch {
|
|
1919
2385
|
return [];
|
|
1920
2386
|
}
|
|
@@ -1931,8 +2397,8 @@ function loadWorkspacePackages(workspaces, root) {
|
|
|
1931
2397
|
const patternEntries = [];
|
|
1932
2398
|
if (typeof workspaces === "string") {
|
|
1933
2399
|
const resolved = path2.resolve(root, workspaces);
|
|
1934
|
-
if (
|
|
1935
|
-
const stat =
|
|
2400
|
+
if (fs2.existsSync(resolved)) {
|
|
2401
|
+
const stat = fs2.statSync(resolved);
|
|
1936
2402
|
if (stat.isDirectory()) {
|
|
1937
2403
|
patternEntries.push({ pattern: workspaces, base: root });
|
|
1938
2404
|
} else if (resolved.endsWith("package.json")) {
|
|
@@ -1980,7 +2446,7 @@ function loadWorkspacePackages(workspaces, root) {
|
|
|
1980
2446
|
for (const { pattern, base } of patternEntries) {
|
|
1981
2447
|
for (const dir of expandGlob(pattern, base)) {
|
|
1982
2448
|
const pkgJson = path2.join(dir, "package.json");
|
|
1983
|
-
if (
|
|
2449
|
+
if (fs2.existsSync(pkgJson)) {
|
|
1984
2450
|
packages.add(dir);
|
|
1985
2451
|
}
|
|
1986
2452
|
}
|
|
@@ -2019,7 +2485,7 @@ function findFiles(root, extensions) {
|
|
|
2019
2485
|
function walk(dir) {
|
|
2020
2486
|
let entries;
|
|
2021
2487
|
try {
|
|
2022
|
-
entries =
|
|
2488
|
+
entries = fs2.readdirSync(dir, { withFileTypes: true });
|
|
2023
2489
|
} catch {
|
|
2024
2490
|
return;
|
|
2025
2491
|
}
|
|
@@ -2049,7 +2515,7 @@ function analyzeProject2(options) {
|
|
|
2049
2515
|
} else {
|
|
2050
2516
|
const files = findFiles(options.root, extensions);
|
|
2051
2517
|
for (const file of files) {
|
|
2052
|
-
const code =
|
|
2518
|
+
const code = fs2.readFileSync(file, "utf-8");
|
|
2053
2519
|
modules.set(file, analyzeModule2(code, file));
|
|
2054
2520
|
}
|
|
2055
2521
|
}
|
|
@@ -2057,7 +2523,7 @@ function analyzeProject2(options) {
|
|
|
2057
2523
|
if (modules.has(filePath))
|
|
2058
2524
|
return modules.get(filePath);
|
|
2059
2525
|
try {
|
|
2060
|
-
const code =
|
|
2526
|
+
const code = fs2.readFileSync(filePath, "utf-8");
|
|
2061
2527
|
const mod = analyzeModule2(code, filePath);
|
|
2062
2528
|
modules.set(filePath, mod);
|
|
2063
2529
|
return mod;
|
|
@@ -2214,17 +2680,17 @@ class AnalyzerCache2 {
|
|
|
2214
2680
|
reactiveProps: serializeReactiveProps(this.reactiveProps)
|
|
2215
2681
|
};
|
|
2216
2682
|
const dir = path2.dirname(this.cachePath);
|
|
2217
|
-
if (!
|
|
2218
|
-
|
|
2683
|
+
if (!fs2.existsSync(dir)) {
|
|
2684
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
2219
2685
|
}
|
|
2220
|
-
|
|
2686
|
+
fs2.writeFileSync(this.cachePath, JSON.stringify(data, null, 2));
|
|
2221
2687
|
}
|
|
2222
2688
|
restore() {
|
|
2223
|
-
if (!this.cachePath || !
|
|
2689
|
+
if (!this.cachePath || !fs2.existsSync(this.cachePath))
|
|
2224
2690
|
return;
|
|
2225
2691
|
let data;
|
|
2226
2692
|
try {
|
|
2227
|
-
data = JSON.parse(
|
|
2693
|
+
data = JSON.parse(fs2.readFileSync(this.cachePath, "utf-8"));
|
|
2228
2694
|
} catch {
|
|
2229
2695
|
return;
|
|
2230
2696
|
}
|
|
@@ -2274,7 +2740,7 @@ class AnalyzerCache2 {
|
|
|
2274
2740
|
if (this.modules.has(filePath))
|
|
2275
2741
|
return this.modules.get(filePath);
|
|
2276
2742
|
try {
|
|
2277
|
-
const code =
|
|
2743
|
+
const code = fs2.readFileSync(filePath, "utf-8");
|
|
2278
2744
|
const mod = analyzeModule2(code, filePath);
|
|
2279
2745
|
this.modules.set(filePath, mod);
|
|
2280
2746
|
return mod;
|
|
@@ -2351,8 +2817,8 @@ function analyze2(options) {
|
|
|
2351
2817
|
root: options.root,
|
|
2352
2818
|
extensions: options.extensions,
|
|
2353
2819
|
resolve: options.resolve,
|
|
2354
|
-
tsConfigPath:
|
|
2355
|
-
bunfigPath:
|
|
2820
|
+
tsConfigPath: fs2.existsSync(tsConfigPath) ? tsConfigPath : undefined,
|
|
2821
|
+
bunfigPath: fs2.existsSync(bunfigPath) ? bunfigPath : undefined,
|
|
2356
2822
|
workspaces: options.workspaces
|
|
2357
2823
|
});
|
|
2358
2824
|
const serialized = {};
|
|
@@ -2364,16 +2830,16 @@ function analyze2(options) {
|
|
|
2364
2830
|
serialized[filePath] = entry;
|
|
2365
2831
|
}
|
|
2366
2832
|
const dir = path2.dirname(options.outFile);
|
|
2367
|
-
if (!
|
|
2368
|
-
|
|
2833
|
+
if (!fs2.existsSync(dir)) {
|
|
2834
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
2369
2835
|
}
|
|
2370
|
-
|
|
2836
|
+
fs2.writeFileSync(options.outFile, JSON.stringify(serialized, null, 2));
|
|
2371
2837
|
}
|
|
2372
2838
|
function loadMetadata2(filePath) {
|
|
2373
|
-
if (!
|
|
2839
|
+
if (!fs2.existsSync(filePath)) {
|
|
2374
2840
|
return new Map;
|
|
2375
2841
|
}
|
|
2376
|
-
const raw = JSON.parse(
|
|
2842
|
+
const raw = JSON.parse(fs2.readFileSync(filePath, "utf-8"));
|
|
2377
2843
|
const result = new Map;
|
|
2378
2844
|
for (const [filePath, fileMap] of Object.entries(raw)) {
|
|
2379
2845
|
const map = new Map;
|
package/dist/cli.js
CHANGED
package/dist/exports.d.ts
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* Shared utility for detecting top-level exported component bindings in a
|
|
5
5
|
* module. Used by the Vite and Bun plugins for HMR boundary injection.
|
|
6
6
|
*
|
|
7
|
-
* Detection is intentionally conservative: only top-level exports
|
|
8
|
-
* starts with an uppercase letter (the component naming
|
|
9
|
-
* returned.
|
|
10
|
-
*
|
|
7
|
+
* Detection is intentionally conservative: only top-level *value* exports
|
|
8
|
+
* whose name starts with an uppercase letter (the component naming
|
|
9
|
+
* convention) are returned. Type-only exports (`export type { Foo }`,
|
|
10
|
+
* `export { type Foo }`) must be skipped: HMR footers reference the local
|
|
11
|
+
* binding, and a type has no runtime binding (`ReferenceError`).
|
|
11
12
|
*/
|
|
12
13
|
export interface ExportedComponent {
|
|
13
14
|
/** Local binding name in the module (referenced in the accept callback). */
|
package/dist/index.js
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
propagateReactiveProps2,
|
|
18
18
|
wrapReactiveExpressions2,
|
|
19
19
|
runAnalyzeCli2
|
|
20
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-sy6qf73w.js";
|
|
21
21
|
|
|
22
22
|
// src/transform.ts
|
|
23
23
|
import { parse } from "@babel/parser";
|
|
@@ -619,6 +619,8 @@ function transformJSX(code, filename, options = {}) {
|
|
|
619
619
|
autoWrapComponents2(ast, filename);
|
|
620
620
|
rewriteLiveCcDestructure(ast);
|
|
621
621
|
wrapReactiveExpressions2(ast, {
|
|
622
|
+
derivedLocals: options.derivedLocals,
|
|
623
|
+
dev: options.dev,
|
|
622
624
|
explicitBindings: options.explicitBindings,
|
|
623
625
|
analyze: options.analyze,
|
|
624
626
|
analyzeMetadata: options.analyzeMetadata,
|
|
@@ -666,7 +668,9 @@ function transformJSX(code, filename, options = {}) {
|
|
|
666
668
|
t2.importSpecifier(t2.identifier("_$bindStyle"), t2.identifier("_$bindStyle")),
|
|
667
669
|
t2.importSpecifier(t2.identifier("_$bindClass"), t2.identifier("_$bindClass"))
|
|
668
670
|
], t2.stringLiteral("sinwan/renderer"));
|
|
669
|
-
ast.program.body.
|
|
671
|
+
bindingImport.specifiers = bindingImport.specifiers.filter((candidate) => !ast.program.body.some((statement) => t2.isImportDeclaration(statement) && statement.source.value === "sinwan/renderer" && statement.specifiers.some((specifier) => t2.isImportSpecifier(specifier) && t2.isIdentifier(specifier.imported) && specifier.local.name === candidate.local.name && specifier.imported.name === candidate.local.name)));
|
|
672
|
+
if (bindingImport.specifiers.length > 0)
|
|
673
|
+
ast.program.body.unshift(bindingImport);
|
|
670
674
|
}
|
|
671
675
|
const result = generate(ast, {
|
|
672
676
|
sourceMaps: true,
|
|
@@ -702,6 +706,9 @@ import { parse as parse2 } from "@babel/parser";
|
|
|
702
706
|
function isComponentName(name) {
|
|
703
707
|
return !!name && name[0] === name[0].toUpperCase() && /^[A-Z]/.test(name);
|
|
704
708
|
}
|
|
709
|
+
function isTypeOnlyExport(node) {
|
|
710
|
+
return node.exportKind === "type";
|
|
711
|
+
}
|
|
705
712
|
function collectExportedComponents(code, filename) {
|
|
706
713
|
let ast;
|
|
707
714
|
try {
|
|
@@ -724,6 +731,8 @@ function collectExportedComponents(code, filename) {
|
|
|
724
731
|
};
|
|
725
732
|
for (const node of ast.program.body) {
|
|
726
733
|
if (node.type === "ExportNamedDeclaration") {
|
|
734
|
+
if (isTypeOnlyExport(node))
|
|
735
|
+
continue;
|
|
727
736
|
if (node.declaration) {
|
|
728
737
|
const decl = node.declaration;
|
|
729
738
|
if (decl.type === "FunctionDeclaration" && decl.id && isComponentName(decl.id.name)) {
|
|
@@ -737,6 +746,8 @@ function collectExportedComponents(code, filename) {
|
|
|
737
746
|
}
|
|
738
747
|
} else if (node.specifiers && !node.source) {
|
|
739
748
|
for (const spec of node.specifiers) {
|
|
749
|
+
if (isTypeOnlyExport(spec))
|
|
750
|
+
continue;
|
|
740
751
|
if (spec.type === "ExportSpecifier" && spec.local?.type === "Identifier" && spec.exported?.type === "Identifier" && isComponentName(spec.exported.name)) {
|
|
741
752
|
add(spec.local.name, spec.exported.name);
|
|
742
753
|
}
|
package/dist/reactive-wrap.d.ts
CHANGED
|
@@ -98,6 +98,8 @@ export declare function collectComponentCallGraph(ast: t.Node, componentNames: M
|
|
|
98
98
|
export declare function buildFullScope(localScopes: Map<t.Function, ReactiveScope>, fn: t.Function, reactiveProps: Set<string>): ReactiveScope | null;
|
|
99
99
|
export declare function propagateReactiveProps(localScopes: Map<t.Function, ReactiveScope>, callGraph: Map<t.Function, CallSite[]>, componentFunctions: Set<t.Function>): Map<t.Function, Set<string>>;
|
|
100
100
|
export declare function wrapReactiveExpressions(ast: t.Node, options?: {
|
|
101
|
+
derivedLocals?: boolean;
|
|
102
|
+
dev?: boolean;
|
|
101
103
|
explicitBindings?: boolean;
|
|
102
104
|
analyze?: string;
|
|
103
105
|
analyzeMetadata?: Map<string, Map<string, Set<string>>>;
|
package/dist/transform.d.ts
CHANGED
package/docs/api.md
CHANGED
|
@@ -122,7 +122,7 @@ Parse `analyze [root] [outFile] [--tsconfig] [--bunfig] [--workspaces]` and call
|
|
|
122
122
|
|
|
123
123
|
| Name | Role |
|
|
124
124
|
| ---- | ---- |
|
|
125
|
-
| `collectExportedComponents(code)` | File-local exported component names (no `cc` required) |
|
|
125
|
+
| `collectExportedComponents(code)` | File-local exported component names (no `cc` required). Skips type-only exports. |
|
|
126
126
|
| `wrapReactiveExpressions(ast, options?)` | Reactive wrap without hoisting |
|
|
127
127
|
| `trackReactiveImports(ast)` | Import-name sets for signals, `cc`, `useFetch`, … |
|
|
128
128
|
| `collectComponentFunctions(ast, ccNames)` | `cc(...)` functions, names, and exports |
|
package/docs/architecture.md
CHANGED
|
@@ -23,6 +23,8 @@ transformJSX
|
|
|
23
23
|
autoWrapComponents
|
|
24
24
|
rewriteLiveCcDestructure ← flat destructure stays live; raw JSX spreads
|
|
25
25
|
wrapReactiveExpressions ← analyze / analyzeMetadata / resolveImport
|
|
26
|
+
promoteReactiveConstInitializers ← opt-in derivedLocals, snapshot diagnostics
|
|
27
|
+
rebuild scopes / call graph / JSX wrapping
|
|
26
28
|
hoist JSXElement roots ← _$createTemplate + slots
|
|
27
29
|
|
|
28
30
|
analyze / AnalyzerCache.update
|
package/docs/plugins.md
CHANGED
|
@@ -48,14 +48,15 @@ The Vite plugin sets `resolve.dedupe: ["sinwan"]` for the same duplicate-runtime
|
|
|
48
48
|
|
|
49
49
|
## Shared plugin options
|
|
50
50
|
|
|
51
|
-
| Option
|
|
52
|
-
|
|
|
53
|
-
| `hoist`
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
57
|
-
| `
|
|
58
|
-
| `
|
|
51
|
+
| Option | Default | Role |
|
|
52
|
+
| ------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
53
|
+
| `hoist` | `true` | Template hoisting |
|
|
54
|
+
| `derivedLocals` | `false` | Promote eligible component-local reactive constants to live getters; see [Transform](transform.md#derived-locals-opt-in). Supports `/** @sinwan-pure */` JSDoc-annotated imports; see [`@sinwan-pure` JSDoc contract](transform.md#sinwan-pure-jsdoc-contract). Requires compiler, plugin and runtime builds with this feature. |
|
|
55
|
+
| `dev` | Bun: `NODE_ENV !== "production"` at `sinwan()`; Vite: `config.mode !== "production"` | Warn when hoisting is skipped. Pass `false` for library/`Bun.build` production bundles. |
|
|
56
|
+
| `explicitBindings` | `false` | `_$bind*` helpers instead of bare `() =>` |
|
|
57
|
+
| `analyze` | unset | Path to CLI JSON (production) |
|
|
58
|
+
| `cache` | `true` | Incremental analyzer in dev. `false` turns it off. An object sets `root`, `tsConfigPath`, `bunfigPath`, `workspaces`, `cachePath` |
|
|
59
|
+
| `fastRefresh` | Bun: same as `dev`; Vite: `true` in `serve` | Per-component `import.meta.hot.accept` so routed/looked-up pages hot-update |
|
|
59
60
|
|
|
60
61
|
## Dev vs production
|
|
61
62
|
|
package/docs/transform.md
CHANGED
|
@@ -76,12 +76,12 @@ export const Child = cc((props) => <span>{() => props.value}</span>);
|
|
|
76
76
|
|
|
77
77
|
Tracked imports:
|
|
78
78
|
|
|
79
|
-
| Module
|
|
80
|
-
|
|
|
81
|
-
| `sinwan/store`
|
|
82
|
-
| `sinwan/reactivity` | `signal`, `computed`
|
|
83
|
-
| `sinwan/react`
|
|
84
|
-
| `sinwan/hook`
|
|
79
|
+
| Module | Names |
|
|
80
|
+
| ------------------- | ------------------------------ |
|
|
81
|
+
| `sinwan/store` | `createMutable`, `createStore` |
|
|
82
|
+
| `sinwan/reactivity` | `signal`, `computed` |
|
|
83
|
+
| `sinwan/react` | `useState` |
|
|
84
|
+
| `sinwan/hook` | `useFetch` |
|
|
85
85
|
|
|
86
86
|
Reads the compiler wraps:
|
|
87
87
|
|
|
@@ -98,11 +98,311 @@ Not wrapped:
|
|
|
98
98
|
|
|
99
99
|
- Literals and plain identifiers
|
|
100
100
|
- Event-handler functions (`onClick={fn}` or `onClick={() => …}`)
|
|
101
|
-
- Nested function
|
|
101
|
+
- Nested function _values_ passed as props (`title={() => s.value}` stays a render prop)
|
|
102
102
|
- Functions stored as object/array values (`{ onClick: () => s.value }`) or returned from a factory (`const make = () => () => s.value`)
|
|
103
103
|
- `useFetch` methods such as `abort` / `execute`
|
|
104
104
|
- Static values the [analyzer](analyzer.md) proved never change
|
|
105
105
|
|
|
106
|
+
## Derived locals (opt-in)
|
|
107
|
+
|
|
108
|
+
JSX expressions containing reactive reads already stay live without an author-written arrow. By default, moving that read into `const open = count.value > 0` still takes a **setup snapshot**. Inline one-off expressions in JSX; use a getter or `computed()` for shared derivations when promotion is disabled.
|
|
109
|
+
|
|
110
|
+
Enable `derivedLocals: true` in `transformJSX` or either plugin to promote eligible component-local constants:
|
|
111
|
+
|
|
112
|
+
```tsx
|
|
113
|
+
export function App() {
|
|
114
|
+
const count = signal(0);
|
|
115
|
+
const positive = count.value > 0;
|
|
116
|
+
const label = positive ? "positive" : "zero";
|
|
117
|
+
return (
|
|
118
|
+
<Show when={positive}>
|
|
119
|
+
<span title={label}>{label}</span>
|
|
120
|
+
</Show>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The derived declarations become zero-argument getters, and references become value reads:
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
const positive = () => count.value > 0;
|
|
129
|
+
const label = () => (positive() ? "positive" : "zero");
|
|
130
|
+
return (
|
|
131
|
+
<Show when={() => positive()}>
|
|
132
|
+
<span title={() => label()}>{() => label()}</span>
|
|
133
|
+
</Show>
|
|
134
|
+
);
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Eligibility and semantics
|
|
138
|
+
|
|
139
|
+
- Only referenced, identifier-named `const` declarations directly in a `cc` setup body (including auto-`cc` components) are candidates. Dependencies are processed in declaration order, including multiple declarators in one statement.
|
|
140
|
+
- Pure read expressions are supported: member/index/optional access, arithmetic, comparisons, boolean/conditional expressions, template strings, object/array literals, and TypeScript expression annotations. Direct imported `useState` getter calls and unshadowed single-argument `Boolean`, `Number`, and `String` coercions are also supported. Property reads and coercions must be free of user-defined side effects.
|
|
141
|
+
- Calls to arbitrary helpers, hooks, factories or methods, assignments, updates, allocations, destructuring, `let`/`var`, mutable captures, and forward/TDZ references remain eager — **unless** the callee is an imported function annotated with `/** @sinwan-pure */` (see [`@sinwan-pure` JSDoc contract](#sinwan-pure-jsdoc-contract) below). Explicit getter and `computed` initializers are unchanged. Module, event-handler, render-callback, loop and nested-block declarations are not promoted.
|
|
142
|
+
- Bare signal/store containers still pass through unchanged. Bare component prop-bag aliases retain their ordinary JavaScript value semantics. References are resolved by lexical binding, so shadowed locals and object keys are not rewritten.
|
|
143
|
+
- Every ordinary value use is called, including chained initializers, closures, event bodies, shorthand object values and setup `if` tests. A boolean does **not** become an accidentally truthy function. Setup control flow still runs once; use `Show`/`Switch` for reactive branches.
|
|
144
|
+
- Variables used as JSX tag names, in type queries, or as mutation/method-call receivers are left eager. Components containing direct `eval` are excluded.
|
|
145
|
+
- Getters are lazy, not cached. Use explicit `computed()` when caching matters. This is not whole-program dataflow, and does not turn arbitrary setup side effects into effects.
|
|
146
|
+
|
|
147
|
+
### Intentional snapshots and diagnostics
|
|
148
|
+
|
|
149
|
+
Put `/* sinwan-snapshot */` immediately before a declaration or its initializer to retain a one-shot read:
|
|
150
|
+
|
|
151
|
+
```tsx
|
|
152
|
+
/* sinwan-snapshot */ const initial = count.value;
|
|
153
|
+
const alsoInitial = /* sinwan-snapshot */ count.value;
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
A declaration-level pragma applies to all its declarators. A snapshot can read another promoted local: the compiler calls that dependency once at the snapshot declaration.
|
|
157
|
+
|
|
158
|
+
With `dev: true`, the compiler warns when an eligible unpromoted derivation is used in JSX while promotion is disabled. It also warns about a promoted local used directly as a setup `if` test, explaining that the value is read once even though the compiler preserves boolean semantics. Intentional snapshot pragmas suppress the snapshot diagnostic; production (`dev: false`) is quiet.
|
|
159
|
+
|
|
160
|
+
### `@sinwan-pure` JSDoc contract
|
|
161
|
+
|
|
162
|
+
The compiler can promote derived `const` initializers that call imported utility functions when those functions are annotated with `/** @sinwan-pure */` in their source file. This eliminates the need for manual `() =>` wrappers around common utilities like className mergers.
|
|
163
|
+
|
|
164
|
+
**Annotation:**
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
/** @sinwan-pure */
|
|
168
|
+
export function cn(...inputs: ClassValue[]): string {
|
|
169
|
+
return twMerge(clsx(inputs));
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Both `export function` and `export const` declarations are recognized. The annotation must be a block comment (`/** */`) immediately before the `export` keyword.
|
|
174
|
+
|
|
175
|
+
**What the compiler guarantees:**
|
|
176
|
+
|
|
177
|
+
- When `derivedLocals: true` is enabled, calls to `@sinwan-pure`-annotated imported functions are treated as safe expressions. The containing `const` initializer is promoted to a zero-argument getter, and references inside the function arguments are rewritten (e.g. `seamInactive` → `seamInactive()`).
|
|
178
|
+
- The compiler resolves the import path, reads the source file, parses it, and checks for `/** @sinwan-pure */` on exported declarations. Results are cached per resolved file path, so each file is read at most once per compilation process.
|
|
179
|
+
- All import types are scanned: relative imports (`./`, `../`), bare specifiers (`clsx`, `sinwan/reactivity`), scoped packages (`@scope/utils`), and subpath imports (`@scope/utils/merge`). The compiler walks up the directory tree to find `node_modules/<pkg>`, reads `package.json`, and resolves the entry point via `exports`, `module`, `main`, `source`, or `index` files.
|
|
180
|
+
- If the `resolveImport` callback is provided by the plugin, it is used for path resolution (handles tsconfig paths, bunfig aliases, workspace packages, and Bun/Vite module resolution). Otherwise, the compiler falls back to direct filesystem resolution with common extensions (`.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`, `.mts`, `.cts`) and `index` files.
|
|
181
|
+
|
|
182
|
+
**Supported export forms (annotate with `/** @sinwan-pure \*/`):\*\*
|
|
183
|
+
|
|
184
|
+
| Export form | Example | Call shape |
|
|
185
|
+
| ---------------------- | ----------------------------------------------------------- | -------------------- |
|
|
186
|
+
| Named function | `/** @sinwan-pure */ export function cn()` | `cn(...)` |
|
|
187
|
+
| Named const (function) | `/** @sinwan-pure */ export const merge = () =>` | `merge(...)` |
|
|
188
|
+
| Named const (object) | `/** @sinwan-pure */ export const helpers = { ... }` | `helpers.merge(...)` |
|
|
189
|
+
| Named class | `/** @sinwan-pure */ export class Utils { static merge() }` | `Utils.merge(...)` |
|
|
190
|
+
| Default function | `/** @sinwan-pure */ export default function helper()` | `helper(...)` |
|
|
191
|
+
| Default arrow | `/** @sinwan-pure */ export default () =>` | `helper(...)` |
|
|
192
|
+
| Default class | `/** @sinwan-pure */ export default class {}` | `helper.merge(...)` |
|
|
193
|
+
|
|
194
|
+
**Supported import forms:**
|
|
195
|
+
|
|
196
|
+
| Import form | Example | Call shape |
|
|
197
|
+
| ---------------------- | --------------------------------------- | ------------- |
|
|
198
|
+
| Named import | `import { cn } from "./utils"` | `cn(...)` |
|
|
199
|
+
| Named import (aliased) | `import { cn as merge } from "./utils"` | `merge(...)` |
|
|
200
|
+
| Default import | `import helper from "./utils"` | `helper(...)` |
|
|
201
|
+
| Namespace import | `import * as ns from "./utils"` | `ns.cn(...)` |
|
|
202
|
+
|
|
203
|
+
**Supported re-export forms:**
|
|
204
|
+
|
|
205
|
+
| Re-export form | Example |
|
|
206
|
+
| ------------------------- | -------------------------------------------- |
|
|
207
|
+
| Named re-export | `export { cn } from "./utils"` |
|
|
208
|
+
| Aliased re-export | `export { cn as merge } from "./utils"` |
|
|
209
|
+
| Default re-export | `export { default } from "./utils"` |
|
|
210
|
+
| Aliased default re-export | `export { default as merge } from "./utils"` |
|
|
211
|
+
| Namespace re-export | `export * from "./utils"` |
|
|
212
|
+
|
|
213
|
+
For class and object exports, any static method or property call on the imported binding is treated as pure (e.g. `Utils.merge(...)`, `Utils.format(...)`, `helpers.merge(...)`, `helpers.format(...)`).
|
|
214
|
+
|
|
215
|
+
**What the annotation author is responsible for:**
|
|
216
|
+
|
|
217
|
+
- **Purity:** The function must not produce side effects — no signal writes, no DOM mutations, no I/O, no global state changes. Calling the function with the same arguments must always return the same value. The compiler does not verify this; it trusts the annotation.
|
|
218
|
+
- **No hidden reactivity:** The function must not read signals, stores, or other reactive sources internally. If it does, those reads will not be tracked because the function is called inside a getter that the runtime evaluates in an effect — but only the arguments passed to the function are tracked, not reads inside the function body.
|
|
219
|
+
- **Export visibility:** The annotation must be on an exported declaration (`export function`, `export const`, `export class`, `export default`). Re-exports are followed recursively — the compiler resolves the re-export source and scans it for the annotation. Named, aliased, default, and `export *` re-exports are all supported. Circular re-exports are handled with a depth limit to prevent infinite loops.
|
|
220
|
+
- **Import resolvability:** The annotated file must be reachable via any import (relative, bare specifier, scoped package, or subpath). The compiler resolves `node_modules` packages via `package.json` `exports`/`module`/`main`/`source` fields and walks up the directory tree.
|
|
221
|
+
|
|
222
|
+
**What happens when the annotation is wrong:**
|
|
223
|
+
|
|
224
|
+
- **Annotated a non-pure function:** The compiler will promote the containing `const` to a getter. If the function has side effects, those side effects will run on every re-evaluation of the getter (whenever its tracked dependencies change), not just once at setup. This will not crash, but may cause unexpected repeated side effects.
|
|
225
|
+
- **Annotated a function that reads signals internally:** Those internal signal reads will not be tracked as dependencies of the promoted getter. The getter will not re-evaluate when those internal signals change, producing stale values. No crash, but incorrect reactivity.
|
|
226
|
+
- **Forgot to annotate a pure function:** The compiler will reject the containing `const` initializer as unsafe. The value will be a one-shot setup snapshot. No crash, but the value will not update when its dependencies change. The `dev: true` diagnostic will warn about eligible unpromoted derivations used in JSX.
|
|
227
|
+
- **Annotation on a non-exported function:** The annotation is ignored. Only exported declarations are scanned.
|
|
228
|
+
- **Import path unresolvable:** The annotation is not found. The function is treated as a non-pure call. No crash, but the containing `const` will not be promoted. This can happen if the package's `package.json` entry points don't lead to a source file, or if the `node_modules` directory is not in the expected location.
|
|
229
|
+
|
|
230
|
+
**Plugin forwarding:**
|
|
231
|
+
|
|
232
|
+
Both `bun-plugin-sinwan` and `vite-plugin-sinwan` forward the `resolveImport` callback to the compiler when the analyzer cache is enabled (`cache: true`, the default). This uses the plugin's module resolver, which handles workspace packages, tsconfig paths, and Bun/Vite resolution. When the cache is disabled or the minimal preload is used, the compiler falls back to direct filesystem resolution, which works for relative imports in a standard project layout.
|
|
233
|
+
|
|
234
|
+
**What is not scanned (by design):**
|
|
235
|
+
|
|
236
|
+
- **Dynamic imports** (`const { cn } = await import("./utils")`) — the binding is established at runtime, not compile time. The compiler cannot statically resolve which module a dynamic import loads, so it cannot scan the source for `@sinwan-pure` annotations. Use a static import instead.
|
|
237
|
+
- **`require()`** (`const { cn } = require("./utils")`) — CommonJS, not used in Sinwan's ESM target. Bun and Vite both use ESM for application code. Annotate the function and import it via a static ESM import.
|
|
238
|
+
- **Computed member access with non-literal keys** (`ns[key]()` where `key` is a variable, or `ns[\`cn`]()` with template literals) — the compiler cannot statically determine which property is being accessed. Use direct member access (`ns.cn()`) or string literal access (`ns["cn"]()`) instead.
|
|
239
|
+
|
|
240
|
+
### Workarounds for unsupported patterns
|
|
241
|
+
|
|
242
|
+
When the compiler cannot auto-promote a derived local (dynamic import, `require()`, or computed member access), use one of these workarounds to preserve reactivity.
|
|
243
|
+
|
|
244
|
+
#### 1. Replace dynamic import with static import (recommended)
|
|
245
|
+
|
|
246
|
+
If the module is available at startup, use a static ESM import instead of `await import()`. The compiler can then scan the source file for `@sinwan-pure` annotations and auto-promote the derived local.
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
// ❌ Does NOT work — dynamic import, binding is runtime
|
|
250
|
+
const { cn } = await import("./utils");
|
|
251
|
+
const cls = cn("base", size.value > 50 && "big");
|
|
252
|
+
|
|
253
|
+
// ✅ Works — static import, compiler auto-promotes
|
|
254
|
+
import { cn } from "./utils"; // /** @sinwan-pure */ export function cn(...)
|
|
255
|
+
const cls = cn("base", size.value > 50 && "big");
|
|
256
|
+
|
|
257
|
+
// Compiled output:
|
|
258
|
+
// const cls = () => cn("base", size.value > 50 && "big");
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
#### 2. Replace `require()` with static ESM import (recommended)
|
|
262
|
+
|
|
263
|
+
Sinwan targets ESM. Use a static ESM import instead of `require()`.
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
// ❌ Does NOT work — CommonJS require(), not ESM
|
|
267
|
+
const { cn } = require("./utils");
|
|
268
|
+
const cls = cn("base", size.value > 50 && "big");
|
|
269
|
+
|
|
270
|
+
// ✅ Works — static ESM import, compiler auto-promotes
|
|
271
|
+
import { cn } from "./utils";
|
|
272
|
+
const cls = cn("base", size.value > 50 && "big");
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
#### 3. Replace computed member access with direct or string literal access (recommended)
|
|
276
|
+
|
|
277
|
+
If you control the call site, use direct member access (`ns.cn()`) or string literal access (`ns["cn"]()`) instead of a variable key.
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
import * as ns from "./utils";
|
|
281
|
+
|
|
282
|
+
// ❌ Does NOT work — variable key, compiler can't resolve
|
|
283
|
+
const key = "cn";
|
|
284
|
+
const cls = ns[key]("base", size.value > 50 && "big");
|
|
285
|
+
|
|
286
|
+
// ❌ Does NOT work — template literal key, compiler can't resolve
|
|
287
|
+
const cls = ns[`cn`]("base", size.value > 50 && "big");
|
|
288
|
+
|
|
289
|
+
// ✅ Works — direct member access, compiler auto-promotes
|
|
290
|
+
const cls = ns.cn("base", size.value > 50 && "big");
|
|
291
|
+
|
|
292
|
+
// ✅ Works — string literal key, compiler auto-promotes
|
|
293
|
+
const cls = ns["cn"]("base", size.value > 50 && "big");
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
#### 4. Use a manual `() =>` getter (fallback for truly dynamic patterns)
|
|
297
|
+
|
|
298
|
+
If you genuinely need dynamic import (e.g. for code-splitting) or computed member access with a variable key, wrap the expression in a manual `() =>` getter. The runtime will re-evaluate it in an effect whenever its tracked dependencies change.
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
import * as ns from "./utils";
|
|
302
|
+
import { computed } from "sinwan/reactivity";
|
|
303
|
+
|
|
304
|
+
// Manual getter — the compiler does not promote it, but the runtime
|
|
305
|
+
// wraps it in an effect so it stays reactive.
|
|
306
|
+
const cls = () => {
|
|
307
|
+
const key = "cn";
|
|
308
|
+
return ns[key]("base", size.value > 50 && "big");
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
// Or use computed() for a memoized version (re-evaluates only when
|
|
312
|
+
// size.value changes, not on every access):
|
|
313
|
+
const clsMemo = computed(() => {
|
|
314
|
+
const key = "cn";
|
|
315
|
+
return ns[key]("base", size.value > 50 && "big");
|
|
316
|
+
});
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
#### 5. Use `computed()` for expensive dynamic patterns
|
|
320
|
+
|
|
321
|
+
If the dynamic expression is expensive (e.g. involves an `await import()`), use `computed()` to memoize the result. The computation runs once and caches until a dependency changes.
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
import { computed } from "sinwan/reactivity";
|
|
325
|
+
|
|
326
|
+
// computed() runs the async import lazily and caches the result.
|
|
327
|
+
// The compiler does not promote this (it's already a getter),
|
|
328
|
+
// but the runtime tracks size.value as a dependency.
|
|
329
|
+
const cls = computed(() => {
|
|
330
|
+
// For dynamic import, you would need to resolve the module
|
|
331
|
+
// outside the computed() and only use the dynamic key inside:
|
|
332
|
+
const key = "cn";
|
|
333
|
+
return ns[key]("base", size.value > 50 && "big");
|
|
334
|
+
});
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
#### Complete demo
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
// lib/utils.ts
|
|
341
|
+
/** @sinwan-pure */
|
|
342
|
+
export function cn(...inputs: unknown[]): string {
|
|
343
|
+
return String(inputs);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** @sinwan-pure */
|
|
347
|
+
export const helpers = {
|
|
348
|
+
merge: (...inputs: unknown[]): string => String(inputs),
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// comp.tsx
|
|
352
|
+
import { cc } from "sinwan/component";
|
|
353
|
+
import { signal, computed } from "sinwan/reactivity";
|
|
354
|
+
import { cn, helpers } from "../lib/utils";
|
|
355
|
+
import * as ns from "../lib/utils";
|
|
356
|
+
|
|
357
|
+
export const App = cc(() => {
|
|
358
|
+
const size = signal(30);
|
|
359
|
+
|
|
360
|
+
// ✅ Auto-promoted — static import + @sinwan-pure
|
|
361
|
+
const a = cn("base", size.value > 50 && "big");
|
|
362
|
+
|
|
363
|
+
// ✅ Auto-promoted — namespace import + direct member access
|
|
364
|
+
const b = ns.cn("base", size.value > 50 && "big");
|
|
365
|
+
|
|
366
|
+
// ✅ Auto-promoted — string literal key
|
|
367
|
+
const c = ns["cn"]("base", size.value > 50 && "big");
|
|
368
|
+
|
|
369
|
+
// ✅ Auto-promoted — object member call
|
|
370
|
+
const d = helpers.merge("base", size.value > 50 && "big");
|
|
371
|
+
|
|
372
|
+
// ✅ Manual getter — for computed member access with variable key
|
|
373
|
+
const e = () => {
|
|
374
|
+
const key = "cn";
|
|
375
|
+
return ns[key]("base", size.value > 50 && "big");
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// ✅ computed() — memoized, re-evaluates only when size.value changes
|
|
379
|
+
const f = computed(() => ns.cn("base", size.value > 50 && "big"));
|
|
380
|
+
|
|
381
|
+
return <div class={a} data-b={b} data-c={c} data-d={d} data-e={e} data-f={f} />;
|
|
382
|
+
});
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Compiled output:
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
const a = () => cn("base", size.value > 50 && "big");
|
|
389
|
+
const b = () => ns.cn("base", size.value > 50 && "big");
|
|
390
|
+
const c = () => ns["cn"]("base", size.value > 50 && "big");
|
|
391
|
+
const d = () => helpers.merge("base", size.value > 50 && "big");
|
|
392
|
+
const e = () => {
|
|
393
|
+
// manual getter — preserved as-is
|
|
394
|
+
const key = "cn";
|
|
395
|
+
return ns[key]("base", size.value > 50 && "big");
|
|
396
|
+
};
|
|
397
|
+
const f = computed(() => ns.cn("base", size.value > 50 && "big")); // already a getter
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Client, SSR and hydration
|
|
401
|
+
|
|
402
|
+
Generated getter calls use the existing JSX wrapping and explicit-binding rules; manual getter identifiers do not need a new blanket wrapping policy. Native attributes must use a runtime build with plain zero-argument getter hydration support. Text, attributes and `Show` remain reactive after hydration; event handlers and nonzero-arity callbacks are not invoked as getters.
|
|
403
|
+
|
|
404
|
+
The runtime's existing complex-template hydration fallback can replace a hoisted server subtree during initial hydration. Promotion does not change that policy; subsequent signal updates do not remount the native shell. Non-hoisted attribute hydration binds to the existing DOM in place.
|
|
405
|
+
|
|
106
406
|
## DOM vs component JSX
|
|
107
407
|
|
|
108
408
|
**Native elements** (`div`, `p`, `button`, …): wrap reactive children and attributes.
|
|
@@ -110,18 +410,18 @@ Not wrapped:
|
|
|
110
410
|
**User components** (`<Label>`, `<Child title={…} />`):
|
|
111
411
|
|
|
112
412
|
- **Children:** wrap reactive reads the same way as DOM text (`{count.value}`, `{checked.value ? "On" : "Off"}`) so `cc()` parents do not snapshot the value. Use a getter (`() => …`), never `_$bindText`, because the child may render nodes as well as strings. Render-prop functions (`{(v) => …}`) stay untouched.
|
|
113
|
-
- **Props:** wrap a prop if it is known-reactive for that component (built-in registry, local call graph, analyzer metadata). For
|
|
413
|
+
- **Props:** wrap a prop if it is known-reactive for that component (built-in registry, local call graph, analyzer metadata). For _unknown_ imported components, still wrap **derived reads** (`htmlFor={id.value}`, `disabled={off.value}`) and leave **container pass-through** alone (`checked={checked}`, `user={user}`) so the child receives the signal or store proxy. With `explicitBindings`, component props stay **getters** (`each={() => …}`), never `_$bindAttr` — `For` / `Show` / `Label` call `resolve(prop)`, which does not unwrap bind descriptors.
|
|
114
414
|
|
|
115
415
|
**Built-in control-flow** components wrap specific props, and they wrap reactive expression children (those children render directly):
|
|
116
416
|
|
|
117
|
-
| Component
|
|
118
|
-
|
|
|
119
|
-
| `For`, `Index`, `Virtual`
|
|
120
|
-
| `Show`, `Switch`, `Match`, `Key` | `when`
|
|
121
|
-
| `Dynamic`
|
|
122
|
-
| `Visible`
|
|
123
|
-
| `Portal`
|
|
124
|
-
| `Activity`
|
|
417
|
+
| Component | Reactive props |
|
|
418
|
+
| -------------------------------- | --------------- |
|
|
419
|
+
| `For`, `Index`, `Virtual` | `each` |
|
|
420
|
+
| `Show`, `Switch`, `Match`, `Key` | `when` |
|
|
421
|
+
| `Dynamic` | `component` |
|
|
422
|
+
| `Visible` | `when`, `style` |
|
|
423
|
+
| `Portal` | `mount` |
|
|
424
|
+
| `Activity` | `mode` |
|
|
125
425
|
|
|
126
426
|
`Suspense` is not in this list; its props are left alone.
|
|
127
427
|
|
|
@@ -131,12 +431,12 @@ Static native trees become a module-level template plus `_$createTemplate(tmpl,
|
|
|
131
431
|
|
|
132
432
|
Slots:
|
|
133
433
|
|
|
134
|
-
| Type
|
|
135
|
-
|
|
|
434
|
+
| Type | When |
|
|
435
|
+
| ------- | ------------------------------------------ |
|
|
136
436
|
| `child` | `{expr}`, or a capitalized child component |
|
|
137
|
-
| `attr`
|
|
138
|
-
| `event` | `onClick={…}` and other `on*` handlers
|
|
139
|
-
| `ref`
|
|
437
|
+
| `attr` | Dynamic attribute (not `on*`) |
|
|
438
|
+
| `event` | `onClick={…}` and other `on*` handlers |
|
|
439
|
+
| `ref` | `ref={fn}` or `ref={object}` |
|
|
140
440
|
|
|
141
441
|
Static `style` objects and strings are written into the HTML (`backgroundColor` → `background-color`). Quoted style strings that contain `${…}` log a warning in the compiler: use a template literal, `style={\`…${x}\`}`.
|
|
142
442
|
|
|
@@ -150,6 +450,7 @@ The slot marker format is `COMPILER_TEMPLATE_SLOT_PROTOCOL` (`s:0`, `s:1`, …).
|
|
|
150
450
|
|
|
151
451
|
```ts
|
|
152
452
|
transformJSX(code, "src/App.tsx", {
|
|
453
|
+
derivedLocals: false,
|
|
153
454
|
hoist: true, // default true
|
|
154
455
|
dev: false, // warn when hoisting is skipped
|
|
155
456
|
explicitBindings: false, // emit _$bindText / _$bindAttr / …
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sinwan-compiler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Shared compiler core for Sinwan plugins",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -42,13 +42,13 @@
|
|
|
42
42
|
"@types/bun": "latest"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@babel/core": "^7.
|
|
46
|
-
"@babel/generator": "^7.
|
|
47
|
-
"@babel/parser": "^7.
|
|
48
|
-
"@babel/traverse": "^7.
|
|
49
|
-
"@babel/types": "^7.
|
|
45
|
+
"@babel/core": "^7.29.7",
|
|
46
|
+
"@babel/generator": "^7.29.8",
|
|
47
|
+
"@babel/parser": "^7.29.8",
|
|
48
|
+
"@babel/traverse": "^7.29.8",
|
|
49
|
+
"@babel/types": "^7.29.8"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"typescript": "
|
|
52
|
+
"typescript": ">=5.0.0 <8.0.0"
|
|
53
53
|
}
|
|
54
54
|
}
|