@sigil-dev/compiler 0.8.6 → 0.9.1

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@sigil-dev/compiler",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.8.6",
5
+ "version": "0.9.1",
6
6
  "private": false,
7
7
  "description": "Compiler for the Sigil framework",
8
8
  "peerDependencies": {
@@ -48,6 +48,7 @@ export function handleGlobalComponent(
48
48
  t.ifStatement(
49
49
  t.binaryExpression(
50
50
  "===",
51
+ //@ts-expect-error i dont think it can be undefined here
51
52
  t.unaryExpression("typeof", targetExpr),
52
53
  t.stringLiteral("undefined"),
53
54
  ),
@@ -56,6 +57,7 @@ export function handleGlobalComponent(
56
57
  t.expressionStatement(
57
58
  t.callExpression(
58
59
  t.memberExpression(
60
+ //@ts-expect-error i dont think it can be undefined here
59
61
  targetExpr,
60
62
  t.identifier("addEventListener"),
61
63
  ),
@@ -67,6 +69,7 @@ export function handleGlobalComponent(
67
69
  [],
68
70
  t.callExpression(
69
71
  t.memberExpression(
72
+ //@ts-expect-error i dont think it can be undefined here
70
73
  targetExpr,
71
74
  t.identifier("removeEventListener"),
72
75
  ),
@@ -1,38 +1,64 @@
1
1
  import type { NodePath } from "@babel/core";
2
2
  import { types as t } from "@babel/core";
3
3
  import { MAGIC } from "../../util/magic.ts";
4
+
4
5
  export function handleState(
5
- declaration: NodePath<t.VariableDeclaration>,
6
- declarator: NodePath<t.VariableDeclarator>,
7
- init: NodePath<t.CallExpression>,
8
- signals: Set<string>,
6
+ declaration: NodePath<t.VariableDeclaration>,
7
+ declarator: NodePath<t.VariableDeclarator>,
8
+ init: NodePath<t.CallExpression>,
9
+ signals: Set<string>,
10
+ dev?: boolean,
11
+ fileKey?: string, // e.g. "src/routes/counter.tsx"
9
12
  ): void {
10
- // AST has to be perfect so we're using type safe
11
- // replacement instead of just swapping method name everywhere.
13
+ const { id } = declarator.node;
14
+ if (!t.isIdentifier(id)) throw new Error("Destructured $state not supported");
15
+ const name = id.name;
12
16
 
13
- const { id } = declarator.node;
14
- if (!t.isIdentifier(id)) throw new Error("Destructured $state not supported");
15
- const name = id.name; // <-- "count"
17
+ // check for // @sigil:reset escape comment
18
+ const hasReset = declaration.node.leadingComments?.some(
19
+ c => c.value.trim() === "@sigil:reset"
20
+ );
16
21
 
17
- const setCall = (
18
- value: t.Expression, // <-- now captures "count"
19
- ) =>
22
+ const setCall = (value: t.Expression) =>
20
23
  t.callExpression(
21
24
  t.memberExpression(t.identifier(name), t.identifier("set")),
22
25
  [value],
23
26
  );
24
-
25
27
  const binding = declaration.scope.getBinding(name);
26
28
  if (!binding) return;
27
- const refs = [...binding.referencePaths];
28
- const violations = [...binding.constantViolations]; // snapshot
29
+ const refs = [...binding.referencePaths];
30
+ const violations = [...binding.constantViolations];
31
+
32
+ const originalArgs = init.node.arguments;
29
33
 
30
- // rewrite declaration
31
- init.replaceWith(
32
- t.callExpression(t.identifier(MAGIC.createSignal), init.node.arguments),
33
- );
34
- declaration.node.kind = "const";
34
+ if (dev && fileKey && !hasReset) {
35
+ const key = `${fileKey}#${name}`;
36
+ // createSignal(__hmrAdopt__("file#count", original))
37
+ init.replaceWith(
38
+ t.callExpression(t.identifier(MAGIC.createSignal), [
39
+ t.callExpression(t.identifier("__hmrAdopt__"), [
40
+ t.stringLiteral(key),
41
+ ...(originalArgs as t.Expression[]),
42
+ ]),
43
+ ])
44
+ );
45
+ // __hmrRegister__("file#count", count) — inserted after declaration
46
+ declaration.insertAfter(
47
+ t.expressionStatement(
48
+ t.callExpression(t.identifier("__hmrRegister__"), [
49
+ t.stringLiteral(key),
50
+ t.identifier(name),
51
+ ])
52
+ )
53
+ );
54
+ } else {
55
+ init.replaceWith(
56
+ t.callExpression(t.identifier(MAGIC.createSignal), originalArgs as t.Expression[])
57
+ );
58
+ }
35
59
 
60
+ declaration.node.kind = "const";
61
+ // ... rest of rewrites unchanged
36
62
  // rewrite writes
37
63
  // violations go before refs becase UpdateExpression
38
64
  // is both a read and a write and this prevents count()++
@@ -33,445 +33,557 @@ const SSR_HELPERS = template.statements.ast(`
33
33
  * Needed for SSR output so hydration can map items by key.
34
34
  */
35
35
  function convertKeyToDataKey(node: t.JSXElement): void {
36
- for (let i = 0; i < node.openingElement.attributes.length; i++) {
37
- const attr = node.openingElement.attributes[i];
38
- if (
39
- t.isJSXAttribute(attr) &&
40
- t.isJSXIdentifier(attr.name) &&
41
- attr.name.name === "key"
42
- ) {
43
- node.openingElement.attributes[i] = t.jsxAttribute(
44
- t.jsxIdentifier("data-key"),
45
- attr.value,
46
- );
47
- }
48
- }
49
- for (const child of node.children) {
50
- if (t.isJSXElement(child)) {
51
- convertKeyToDataKey(child);
52
- } else if (
53
- t.isJSXExpressionContainer(child) &&
54
- t.isJSXElement(child.expression)
55
- ) {
56
- convertKeyToDataKey(child.expression);
57
- }
58
- }
36
+ for (let i = 0; i < node.openingElement.attributes.length; i++) {
37
+ const attr = node.openingElement.attributes[i];
38
+ if (
39
+ t.isJSXAttribute(attr) &&
40
+ t.isJSXIdentifier(attr.name) &&
41
+ attr.name.name === "key"
42
+ ) {
43
+ node.openingElement.attributes[i] = t.jsxAttribute(
44
+ t.jsxIdentifier("data-key"),
45
+ attr.value,
46
+ );
47
+ }
48
+ }
49
+ for (const child of node.children) {
50
+ if (t.isJSXElement(child)) {
51
+ convertKeyToDataKey(child);
52
+ } else if (
53
+ t.isJSXExpressionContainer(child) &&
54
+ t.isJSXElement(child.expression)
55
+ ) {
56
+ convertKeyToDataKey(child.expression);
57
+ }
58
+ }
59
59
  }
60
60
 
61
61
  interface SigilOptions {
62
- hash?: string;
63
- mode?: "dom" | "ssr" | "hydrate";
62
+ hash?: string;
63
+ mode?: "dom" | "ssr" | "hydrate";
64
+ dev?: boolean
64
65
  }
65
66
 
66
67
  export default function sigilPlugin(): PluginObject {
67
- const signals = new Set<string>();
68
- const storeSignals = new Set<string>();
69
- let scopedHash: string | undefined;
70
- let isSSR = false;
71
- let isHydrate = false;
72
- let didTransform = false;
73
-
74
- return {
75
- name: "sigil",
76
- visitor: {
77
- Program: {
78
- enter(path, state) {
79
- signals.clear();
80
- storeSignals.clear();
81
- const opts = (state.opts || {}) as SigilOptions;
82
- scopedHash = opts.hash;
83
- didTransform = false;
84
- isSSR = opts.mode === "ssr";
85
- isHydrate = opts.mode === "hydrate";
86
-
87
- if (isSSR) {
88
- path.unshiftContainer("body", SSR_HELPERS);
89
- return;
90
- }
91
- },
92
- exit(path, state) {
93
- warnDeadReactivity(path, signals, storeSignals, state.filename);
94
-
95
- // Only inject runtime import if this file actually used sigil transforms
96
- if (!didTransform || isSSR) return;
97
-
98
- const requiredSpecifiers = [
99
- "createSignal",
100
- "createRawSignal",
101
- "createEffect",
102
- "createMemo",
103
- "reconcile",
104
- "claim",
105
- "claimText",
106
- "claimComment",
107
- "hydrateKeyedList",
108
- "insert",
109
- "getHydrationNodes",
110
- "pushHydrationNodes",
111
- "popHydrationNodes",
112
- "snapshot",
113
- "tracking",
114
- "createEffectPre",
115
- "createInspectEffect",
116
- "withEffectScope",
117
- ];
118
- const runtimeImport = path.node.body.find(
119
- (n): n is t.ImportDeclaration =>
120
- t.isImportDeclaration(n) &&
121
- n.source.value === "@sigil-dev/runtime",
122
- );
123
-
124
- if (runtimeImport) {
125
- // console.log(
126
- // "[sigil exit] found existing import, specifier count:",
127
- // runtimeImport.specifiers.length,
128
- // );
129
- const existingNames = new Set(
130
- runtimeImport.specifiers
131
- .filter(t.isImportSpecifier)
132
- .map((s) =>
133
- t.isIdentifier(s.imported)
134
- ? s.imported.name
135
- : (s.imported as t.StringLiteral).value,
136
- ),
137
- );
138
- for (const name of requiredSpecifiers) {
139
- if (!existingNames.has(name)) {
140
- runtimeImport.specifiers.push(
141
- t.importSpecifier(t.identifier(name), t.identifier(name)),
142
- );
143
- }
144
- }
145
- } else {
146
- path.unshiftContainer(
147
- "body",
148
- t.importDeclaration(
149
- requiredSpecifiers.map((name) =>
150
- t.importSpecifier(t.identifier(name), t.identifier(name)),
151
- ),
152
- t.stringLiteral("@sigil-dev/runtime"),
153
- ),
154
- );
155
- }
156
- },
157
- },
158
- VariableDeclaration(path) {
159
- for (const declarator of path.get("declarations")) {
160
- const macro = getMacro(declarator);
161
- if (!macro) continue;
162
- didTransform = true;
163
- const { name, init } = macro;
164
- const varName = t.isIdentifier(declarator.node.id)
165
- ? declarator.node.id.name
166
- : null;
167
-
168
- if (name === "$state" || name === "$store" || name === "$state.raw") {
169
- if (name === "$state.raw") {
170
- if (!isSSR) {
171
- const args = init.node.arguments;
172
- init.replaceWith(
173
- t.callExpression(t.identifier(MAGIC.createRawSignal), args),
174
- );
175
- }
176
- continue;
177
- }
178
- isSSR
179
- ? handleStateSSR(path, declarator, init, signals)
180
- : handleState(path, declarator, init, signals);
181
- if (name === "$store" && varName) {
182
- storeSignals.add(varName);
183
- }
184
- } else if (name === "$derived") {
185
- isSSR
186
- ? handleDerivedSSR(path, declarator, init, signals)
187
- : handleDerived(path, declarator, init, signals);
188
- }
189
- }
190
- },
191
- ExpressionStatement(path) {
192
- if (isSSR) {
193
- // drop $effect, $inspect, and $debug entirely in SSR
194
- const expr = path.get("expression");
195
- if (expr.isCallExpression()) {
196
- const callee = expr.get("callee");
197
- if (
198
- callee.isIdentifier() &&
199
- (callee.node.name === "$effect" ||
200
- callee.node.name === "$inspect" ||
201
- callee.node.name === "$debug")
202
- ) {
203
- didTransform = true;
204
- path.remove();
205
- }
206
- }
207
- return;
208
- }
209
- handleEffect(path);
210
- // Handle $debug(expr) expression statements
211
- const expr = path.get("expression");
212
- if (expr.isCallExpression()) {
213
- const callee = expr.get("callee");
214
- if (callee.isIdentifier({ name: "$debug" })) {
215
- didTransform = true;
216
- path.replaceWith(
217
- t.ifStatement(
218
- t.memberExpression(
219
- t.memberExpression(
220
- t.memberExpression(
221
- t.identifier("import"),
222
- t.identifier("meta"),
223
- ),
224
- t.identifier("env"),
225
- ),
226
- t.identifier("DEV"),
227
- ),
228
- t.blockStatement([
229
- t.debuggerStatement(),
230
- t.expressionStatement(
231
- t.callExpression(
232
- t.memberExpression(
233
- t.identifier("console"),
234
- t.identifier("log"),
235
- ),
236
- expr.node.arguments,
237
- ),
238
- ),
239
- ]),
240
- ),
241
- );
242
- }
243
- }
244
- },
245
- CallExpression(path) {
246
- if (isSSR) return;
247
- const callee = path.get("callee");
248
-
249
- // Helper: wrap signal identifiers in call expressions
250
- const wrapSignal = (expr: t.Expression): t.Expression => {
251
- if (t.isIdentifier(expr) && signals.has(expr.name)) {
252
- return t.callExpression(expr, []);
253
- }
254
- return expr;
255
- };
256
-
257
- // Handle member expression callees: $state.snapshot(x), $effect.tracking(), $effect.pre(fn)
258
- if (callee.isMemberExpression()) {
259
- const object = callee.get("object");
260
- const property = callee.get("property");
261
- if (object.isIdentifier() && property.isIdentifier()) {
262
- const obj = object.node.name;
263
- const prop = property.node.name;
264
-
265
- if (obj === "$state" && prop === "snapshot") {
266
- didTransform = true;
267
- path.get("callee").replaceWith(t.identifier(MAGIC.snapshot));
268
- return;
269
- }
270
-
271
- if (obj === "$effect" && prop === "tracking") {
272
- didTransform = true;
273
- path.get("callee").replaceWith(t.identifier(MAGIC.tracking));
274
- return;
275
- }
276
-
277
- if (obj === "$effect" && prop === "pre") {
278
- didTransform = true;
279
- path
280
- .get("callee")
281
- .replaceWith(t.identifier(MAGIC.createEffectPre));
282
- return;
283
- }
284
- }
285
- }
286
-
287
- // Handle $inspect(count).with(cb) chain
288
- if (callee.isMemberExpression()) {
289
- const property = callee.get("property");
290
- if (property.isIdentifier({ name: "with" })) {
291
- const object = callee.get("object");
292
- if (object.isCallExpression()) {
293
- const innerCallee = object.get("callee");
294
- if (innerCallee.isIdentifier({ name: "$inspect" })) {
295
- didTransform = true;
296
- //@ts-expect-error Bizzare but it works if you read LSP
297
- const args = object.node.arguments.map(wrapSignal);
298
- const cb = path.node.arguments[0];
299
- path.replaceWith(
300
- t.callExpression(t.identifier(MAGIC.createInspectEffect), [
301
- t.arrowFunctionExpression([], t.arrayExpression(args)),
302
- //@ts-expect-error Bizzare but it works if you read LSP
303
- cb,
304
- ]),
305
- );
306
- return;
307
- }
308
- }
309
- }
310
- }
311
-
312
- // Handle standalone $debug(...) — not as expression statement
313
- if (callee.isIdentifier({ name: "$debug" })) {
314
- didTransform = true;
315
- path.replaceWith(
316
- t.ifStatement(
317
- t.memberExpression(
318
- t.memberExpression(
319
- t.memberExpression(
320
- t.identifier("import"),
321
- t.identifier("meta"),
322
- ),
323
- t.identifier("env"),
324
- ),
325
- t.identifier("DEV"),
326
- ),
327
- t.blockStatement([
328
- t.debuggerStatement(),
329
- t.expressionStatement(
330
- t.callExpression(
331
- t.memberExpression(
332
- t.identifier("console"),
333
- t.identifier("log"),
334
- ),
335
- path.node.arguments,
336
- ),
337
- ),
338
- ]),
339
- ),
340
- );
341
- return;
342
- }
343
-
344
- // Handle standalone $inspect(...)
345
- if (callee.isIdentifier({ name: "$inspect" })) {
346
- didTransform = true;
347
- //@ts-expect-error Bizzare but it works if you read LSP
348
- const args = path.node.arguments.map(wrapSignal);
349
- path.replaceWith(
350
- t.callExpression(t.identifier(MAGIC.createInspectEffect), [
351
- t.arrowFunctionExpression([], t.arrayExpression(args)),
352
- ]),
353
- );
354
- return;
355
- }
356
- },
357
- JSXElement(path) {
358
- didTransform = true;
359
- // console.log(
360
- // "[sigil] JSXElement visitor fired, parent:",
361
- // path.parentPath?.type,
362
- // "isSSR:", isSSR
363
- // );
364
- if (path.parentPath?.isJSXElement() || path.parentPath?.isJSXFragment())
365
- return;
366
- if (isSSR) {
367
- try {
368
- convertKeyToDataKey(path.node);
369
- path.replaceWith(
370
- t.callExpression(t.identifier("__h"), [
371
- processElementSSR(path.node, signals),
372
- ]),
373
- );
374
- } catch (e) {
375
- console.error("[sigil] processElementSSR threw:", path.toString());
376
- console.error(e);
377
- throw e;
378
- }
379
- return;
380
- }
381
-
382
- const statements: t.Statement[] = [];
383
- const genId = (() => {
384
- let i = 0;
385
- return () => `_el${i++}`;
386
- })();
387
-
388
- const root = processElement(
389
- path.node,
390
- statements,
391
- genId,
392
- signals,
393
- scopedHash,
394
- isHydrate,
395
- isHydrate ? "__nodes" : undefined,
396
- );
397
-
398
- const body: t.Statement[] = [];
399
-
400
- if (isHydrate) {
401
- // DECLARE __nodes locally — every IIFE owns its own context
402
- body.push(
403
- t.variableDeclaration("const", [
404
- t.variableDeclarator(
405
- t.identifier("__nodes"),
406
- t.callExpression(t.identifier("getHydrationNodes"), []),
407
- ),
408
- ]),
409
- );
410
- }
411
-
412
- body.push(...statements);
413
- body.push(t.returnStatement(t.identifier(root)));
414
-
415
- path.replaceWith(
416
- t.callExpression(
417
- t.arrowFunctionExpression([], t.blockStatement(body)),
418
- [],
419
- ),
420
- );
421
- },
422
- JSXFragment(path) {
423
- didTransform = true;
424
- if (path.parentPath?.isJSXElement() || path.parentPath?.isJSXFragment())
425
- return;
426
- if (isSSR) {
427
- path.replaceWith(
428
- t.callExpression(t.identifier("__h"), [
429
- processFragmentSSR(path.node, signals),
430
- ]),
431
- );
432
- return;
433
- }
434
-
435
- const statements: t.Statement[] = [];
436
- const genId = (() => {
437
- let i = 0;
438
- return () => `_el${i++}`;
439
- })();
440
-
441
- const root = processFragment(
442
- path.node,
443
- statements,
444
- genId,
445
- signals,
446
- scopedHash,
447
- isHydrate,
448
- isHydrate ? "__nodes" : undefined,
449
- );
450
-
451
- const body: t.Statement[] = [];
452
-
453
- if (isHydrate) {
454
- // DECLARE __nodes locally — every IIFE owns its own context
455
- body.push(
456
- t.variableDeclaration("const", [
457
- t.variableDeclarator(
458
- t.identifier("__nodes"),
459
- t.callExpression(t.identifier("getHydrationNodes"), []),
460
- ),
461
- ]),
462
- );
463
- }
464
-
465
- body.push(...statements);
466
- body.push(t.returnStatement(t.identifier(root)));
467
-
468
- path.replaceWith(
469
- t.callExpression(
470
- t.arrowFunctionExpression([], t.blockStatement(body)),
471
- [],
472
- ),
473
- );
474
- },
475
- },
476
- };
68
+ const signals = new Set<string>();
69
+ const storeSignals = new Set<string>();
70
+ const asyncFunctions = new Map<string, any>();
71
+
72
+ let scopedHash: string | undefined;
73
+ let isSSR = false;
74
+ let isHydrate = false;
75
+ let didTransform = false;
76
+
77
+ let isDev = false;
78
+ let filename = "";
79
+
80
+ let asyncDefaultPath: any = null;
81
+ let asyncDefaultRef: string | null = null;
82
+ let hasLoadingExport = false;
83
+ let hasErrorExport = false;
84
+
85
+ return {
86
+ name: "sigil",
87
+ visitor: {
88
+ Program: {
89
+ enter(path, state) {
90
+ signals.clear();
91
+ storeSignals.clear();
92
+ const opts = (state.opts || {}) as SigilOptions;
93
+ scopedHash = opts.hash;
94
+ didTransform = false;
95
+ isSSR = opts.mode === "ssr";
96
+ isHydrate = opts.mode === "hydrate";
97
+ isDev = opts.dev ?? false;
98
+ filename = state.filename ?? "";
99
+ asyncDefaultPath = null;
100
+ hasLoadingExport = false;
101
+ hasErrorExport = false;
102
+ asyncDefaultRef = null;
103
+ asyncFunctions.clear();
104
+
105
+ if (isSSR) {
106
+ path.unshiftContainer("body", SSR_HELPERS);
107
+ return;
108
+ }
109
+ },
110
+ exit(path, state) {
111
+ warnDeadReactivity(path, signals, storeSignals, state.filename);
112
+
113
+ if (asyncDefaultPath && !isSSR) {
114
+ let fnNode: t.FunctionDeclaration;
115
+
116
+ if (asyncDefaultPath.isExportDefaultDeclaration()) {
117
+ // export default async function Page() {}
118
+ fnNode = asyncDefaultPath.get("declaration").node as t.FunctionDeclaration;
119
+ } else {
120
+ // async function Page() {} + export default Page
121
+ fnNode = asyncDefaultPath.node as t.FunctionDeclaration;
122
+ }
123
+
124
+ const originalName = fnNode.id?.name ?? "Component";
125
+ const privateName = `__${originalName}`;
126
+ const propsId = t.identifier("__props");
127
+
128
+ const privateFn = t.functionDeclaration(
129
+ t.identifier(privateName),
130
+ fnNode.params,
131
+ fnNode.body,
132
+ false,
133
+ true,
134
+ );
135
+
136
+ const wrapperExport = t.exportDefaultDeclaration(
137
+ t.functionDeclaration(
138
+ t.identifier(originalName),
139
+ [propsId],
140
+ t.blockStatement([
141
+ t.returnStatement(
142
+ t.callExpression(t.identifier("__asyncBoundary"), [
143
+ t.arrowFunctionExpression(
144
+ [],
145
+ t.callExpression(t.identifier(privateName), [propsId]),
146
+ ),
147
+ hasLoadingExport ? t.identifier("loading") : t.identifier("undefined"),
148
+ hasErrorExport ? t.identifier("error") : t.identifier("undefined"),
149
+ ]),
150
+ ),
151
+ ]),
152
+ ),
153
+ );
154
+
155
+ asyncDefaultPath.replaceWithMultiple([privateFn, wrapperExport]);
156
+ didTransform = true;
157
+ }
158
+
159
+ // Only inject runtime import if this file actually used sigil transforms
160
+ if (!didTransform || isSSR) return;
161
+
162
+ const requiredSpecifiers = [
163
+ "createSignal",
164
+ "createRawSignal",
165
+ "createEffect",
166
+ "createMemo",
167
+ "reconcile",
168
+ "claim",
169
+ "claimText",
170
+ "claimComment",
171
+ "hydrateKeyedList",
172
+ "insert",
173
+ "getHydrationNodes",
174
+ "pushHydrationNodes",
175
+ "popHydrationNodes",
176
+ "snapshot",
177
+ "tracking",
178
+ "createEffectPre",
179
+ "createInspectEffect",
180
+ "withEffectScope",
181
+ ...(asyncDefaultPath ? ["__asyncBoundary"] : []),
182
+ ];
183
+ const runtimeImport = path.node.body.find(
184
+ (n): n is t.ImportDeclaration =>
185
+ t.isImportDeclaration(n) &&
186
+ n.source.value === "@sigil-dev/runtime",
187
+ );
188
+
189
+ if (runtimeImport) {
190
+ // console.log(
191
+ // "[sigil exit] found existing import, specifier count:",
192
+ // runtimeImport.specifiers.length,
193
+ // );
194
+ const existingNames = new Set(
195
+ runtimeImport.specifiers
196
+ .filter(t.isImportSpecifier)
197
+ .map((s) =>
198
+ t.isIdentifier(s.imported)
199
+ ? s.imported.name
200
+ : (s.imported as t.StringLiteral).value,
201
+ ),
202
+ );
203
+ for (const name of requiredSpecifiers) {
204
+ if (!existingNames.has(name)) {
205
+ runtimeImport.specifiers.push(
206
+ t.importSpecifier(t.identifier(name), t.identifier(name)),
207
+ );
208
+ }
209
+ }
210
+ } else {
211
+ path.unshiftContainer(
212
+ "body",
213
+ t.importDeclaration(
214
+ requiredSpecifiers.map((name) =>
215
+ t.importSpecifier(t.identifier(name), t.identifier(name)),
216
+ ),
217
+ t.stringLiteral("@sigil-dev/runtime"),
218
+ ),
219
+ );
220
+ }
221
+ },
222
+ },
223
+ VariableDeclaration(path) {
224
+ for (const declarator of path.get("declarations")) {
225
+ const macro = getMacro(declarator);
226
+ if (!macro) continue;
227
+ didTransform = true;
228
+ const { name, init } = macro;
229
+ const varName = t.isIdentifier(declarator.node.id)
230
+ ? declarator.node.id.name
231
+ : null;
232
+
233
+ if (name === "$state" || name === "$store" || name === "$state.raw") {
234
+ if (name === "$state.raw") {
235
+ if (!isSSR) {
236
+ const args = init.node.arguments;
237
+ init.replaceWith(
238
+ t.callExpression(t.identifier(MAGIC.createRawSignal), args),
239
+ );
240
+ }
241
+ continue;
242
+ }
243
+ isSSR
244
+ ? handleStateSSR(path, declarator, init, signals)
245
+ : handleState(path, declarator, init, signals, isDev, filename); // ← add
246
+ if (name === "$store" && varName) {
247
+ storeSignals.add(varName);
248
+ }
249
+ } else if (name === "$derived") {
250
+ isSSR
251
+ ? handleDerivedSSR(path, declarator, init, signals)
252
+ : handleDerived(path, declarator, init, signals);
253
+ }
254
+ }
255
+ },
256
+ ExpressionStatement(path) {
257
+ if (isSSR) {
258
+ // drop $effect, $inspect, and $debug entirely in SSR
259
+ const expr = path.get("expression");
260
+ if (expr.isCallExpression()) {
261
+ const callee = expr.get("callee");
262
+ if (
263
+ callee.isIdentifier() &&
264
+ (callee.node.name === "$effect" ||
265
+ callee.node.name === "$inspect" ||
266
+ callee.node.name === "$debug")
267
+ ) {
268
+ didTransform = true;
269
+ path.remove();
270
+ }
271
+ }
272
+ return;
273
+ }
274
+ handleEffect(path);
275
+ // Handle $debug(expr) expression statements
276
+ const expr = path.get("expression");
277
+ if (expr.isCallExpression()) {
278
+ const callee = expr.get("callee");
279
+ if (callee.isIdentifier({ name: "$debug" })) {
280
+ didTransform = true;
281
+ path.replaceWith(
282
+ t.ifStatement(
283
+ t.memberExpression(
284
+ t.memberExpression(
285
+ t.memberExpression(
286
+ t.identifier("import"),
287
+ t.identifier("meta"),
288
+ ),
289
+ t.identifier("env"),
290
+ ),
291
+ t.identifier("DEV"),
292
+ ),
293
+ t.blockStatement([
294
+ t.debuggerStatement(),
295
+ t.expressionStatement(
296
+ t.callExpression(
297
+ t.memberExpression(
298
+ t.identifier("console"),
299
+ t.identifier("log"),
300
+ ),
301
+ expr.node.arguments,
302
+ ),
303
+ ),
304
+ ]),
305
+ ),
306
+ );
307
+ }
308
+ }
309
+ },
310
+ CallExpression(path) {
311
+ if (isSSR) return;
312
+ const callee = path.get("callee");
313
+
314
+ // Helper: wrap signal identifiers in call expressions
315
+ const wrapSignal = (expr: t.Expression): t.Expression => {
316
+ if (t.isIdentifier(expr) && signals.has(expr.name)) {
317
+ return t.callExpression(expr, []);
318
+ }
319
+ return expr;
320
+ };
321
+
322
+ // Handle member expression callees: $state.snapshot(x), $effect.tracking(), $effect.pre(fn)
323
+ if (callee.isMemberExpression()) {
324
+ const object = callee.get("object");
325
+ const property = callee.get("property");
326
+ if (object.isIdentifier() && property.isIdentifier()) {
327
+ const obj = object.node.name;
328
+ const prop = property.node.name;
329
+
330
+ if (obj === "$state" && prop === "snapshot") {
331
+ didTransform = true;
332
+ path.get("callee").replaceWith(t.identifier(MAGIC.snapshot));
333
+ return;
334
+ }
335
+
336
+ if (obj === "$effect" && prop === "tracking") {
337
+ didTransform = true;
338
+ path.get("callee").replaceWith(t.identifier(MAGIC.tracking));
339
+ return;
340
+ }
341
+
342
+ if (obj === "$effect" && prop === "pre") {
343
+ didTransform = true;
344
+ path
345
+ .get("callee")
346
+ .replaceWith(t.identifier(MAGIC.createEffectPre));
347
+ return;
348
+ }
349
+ }
350
+ }
351
+
352
+ // Handle $inspect(count).with(cb) chain
353
+ if (callee.isMemberExpression()) {
354
+ const property = callee.get("property");
355
+ if (property.isIdentifier({ name: "with" })) {
356
+ const object = callee.get("object");
357
+ if (object.isCallExpression()) {
358
+ const innerCallee = object.get("callee");
359
+ if (innerCallee.isIdentifier({ name: "$inspect" })) {
360
+ didTransform = true;
361
+ //@ts-expect-error Bizzare but it works if you read LSP
362
+ const args = object.node.arguments.map(wrapSignal);
363
+ const cb = path.node.arguments[0];
364
+ path.replaceWith(
365
+ t.callExpression(t.identifier(MAGIC.createInspectEffect), [
366
+ t.arrowFunctionExpression([], t.arrayExpression(args)),
367
+ //@ts-expect-error Bizzare but it works if you read LSP
368
+ cb,
369
+ ]),
370
+ );
371
+ return;
372
+ }
373
+ }
374
+ }
375
+ }
376
+
377
+ // Handle standalone $debug(...) — not as expression statement
378
+ if (callee.isIdentifier({ name: "$debug" })) {
379
+ didTransform = true;
380
+ path.replaceWith(
381
+ t.ifStatement(
382
+ t.memberExpression(
383
+ t.memberExpression(
384
+ t.memberExpression(
385
+ t.identifier("import"),
386
+ t.identifier("meta"),
387
+ ),
388
+ t.identifier("env"),
389
+ ),
390
+ t.identifier("DEV"),
391
+ ),
392
+ t.blockStatement([
393
+ t.debuggerStatement(),
394
+ t.expressionStatement(
395
+ t.callExpression(
396
+ t.memberExpression(
397
+ t.identifier("console"),
398
+ t.identifier("log"),
399
+ ),
400
+ path.node.arguments,
401
+ ),
402
+ ),
403
+ ]),
404
+ ),
405
+ );
406
+ return;
407
+ }
408
+
409
+ // Handle standalone $inspect(...)
410
+ if (callee.isIdentifier({ name: "$inspect" })) {
411
+ didTransform = true;
412
+ //@ts-expect-error Bizzare but it works if you read LSP
413
+ const args = path.node.arguments.map(wrapSignal);
414
+ path.replaceWith(
415
+ t.callExpression(t.identifier(MAGIC.createInspectEffect), [
416
+ t.arrowFunctionExpression([], t.arrayExpression(args)),
417
+ ]),
418
+ );
419
+ return;
420
+ }
421
+ },
422
+ JSXElement(path) {
423
+ didTransform = true;
424
+ // console.log(
425
+ // "[sigil] JSXElement visitor fired, parent:",
426
+ // path.parentPath?.type,
427
+ // "isSSR:", isSSR
428
+ // );
429
+ if (path.parentPath?.isJSXElement() || path.parentPath?.isJSXFragment())
430
+ return;
431
+ if (isSSR) {
432
+ try {
433
+ convertKeyToDataKey(path.node);
434
+ path.replaceWith(
435
+ t.callExpression(t.identifier("__h"), [
436
+ processElementSSR(path.node, signals),
437
+ ]),
438
+ );
439
+ } catch (e) {
440
+ console.error("[sigil] processElementSSR threw:", path.toString());
441
+ console.error(e);
442
+ throw e;
443
+ }
444
+ return;
445
+ }
446
+
447
+ const statements: t.Statement[] = [];
448
+ const genId = (() => {
449
+ let i = 0;
450
+ return () => `_el${i++}`;
451
+ })();
452
+
453
+ const root = processElement(
454
+ path.node,
455
+ statements,
456
+ genId,
457
+ signals,
458
+ scopedHash,
459
+ isHydrate,
460
+ isHydrate ? "__nodes" : undefined,
461
+ );
462
+
463
+ const body: t.Statement[] = [];
464
+
465
+ if (isHydrate) {
466
+ // DECLARE __nodes locally — every IIFE owns its own context
467
+ body.push(
468
+ t.variableDeclaration("const", [
469
+ t.variableDeclarator(
470
+ t.identifier("__nodes"),
471
+ t.callExpression(t.identifier("getHydrationNodes"), []),
472
+ ),
473
+ ]),
474
+ );
475
+ }
476
+
477
+ body.push(...statements);
478
+ body.push(t.returnStatement(t.identifier(root)));
479
+
480
+ path.replaceWith(
481
+ t.callExpression(
482
+ t.arrowFunctionExpression([], t.blockStatement(body)),
483
+ [],
484
+ ),
485
+ );
486
+ },
487
+ JSXFragment(path) {
488
+ didTransform = true;
489
+ if (path.parentPath?.isJSXElement() || path.parentPath?.isJSXFragment())
490
+ return;
491
+ if (isSSR) {
492
+ path.replaceWith(
493
+ t.callExpression(t.identifier("__h"), [
494
+ processFragmentSSR(path.node, signals),
495
+ ]),
496
+ );
497
+ return;
498
+ }
499
+
500
+ const statements: t.Statement[] = [];
501
+ const genId = (() => {
502
+ let i = 0;
503
+ return () => `_el${i++}`;
504
+ })();
505
+
506
+ const root = processFragment(
507
+ path.node,
508
+ statements,
509
+ genId,
510
+ signals,
511
+ scopedHash,
512
+ isHydrate,
513
+ isHydrate ? "__nodes" : undefined,
514
+ );
515
+
516
+ const body: t.Statement[] = [];
517
+
518
+ if (isHydrate) {
519
+ // DECLARE __nodes locally — every IIFE owns its own context
520
+ body.push(
521
+ t.variableDeclaration("const", [
522
+ t.variableDeclarator(
523
+ t.identifier("__nodes"),
524
+ t.callExpression(t.identifier("getHydrationNodes"), []),
525
+ ),
526
+ ]),
527
+ );
528
+ }
529
+
530
+ body.push(...statements);
531
+ body.push(t.returnStatement(t.identifier(root)));
532
+
533
+ path.replaceWith(
534
+ t.callExpression(
535
+ t.arrowFunctionExpression([], t.blockStatement(body)),
536
+ [],
537
+ ),
538
+ );
539
+ },
540
+ FunctionDeclaration(path) {
541
+ if (isSSR) return;
542
+ const node = path.node;
543
+ if (node.async && node.id) {
544
+ asyncFunctions.set(node.id.name, path);
545
+ }
546
+ },
547
+
548
+ ExportDefaultDeclaration(path) {
549
+ const decl = path.get("declaration");
550
+
551
+ // export default async function Page() {}
552
+ if (
553
+ (decl.isFunctionDeclaration() || decl.isFunctionExpression()) &&
554
+ (decl.node as any).async &&
555
+ !isSSR
556
+ ) {
557
+ asyncDefaultPath = path;
558
+ return;
559
+ }
560
+
561
+ // export default Page
562
+ if (decl.isIdentifier() && !isSSR) {
563
+ const name = (decl.node as t.Identifier).name;
564
+ if (asyncFunctions.has(name)) {
565
+ asyncDefaultPath = asyncFunctions.get(name);
566
+ asyncDefaultRef = name;
567
+ }
568
+ }
569
+ },
570
+
571
+ ExportNamedDeclaration(path) {
572
+ const decl = path.get("declaration");
573
+ if (decl.isFunctionDeclaration()) {
574
+ const name = (decl.node as t.FunctionDeclaration).id?.name;
575
+ if (name === "loading") hasLoadingExport = true;
576
+ if (name === "error") hasErrorExport = true;
577
+ }
578
+ if (decl.isVariableDeclaration()) {
579
+ for (const d of decl.node.declarations) {
580
+ if (t.isIdentifier(d.id)) {
581
+ if (d.id.name === "loading") hasLoadingExport = true;
582
+ if (d.id.name === "error") hasErrorExport = true;
583
+ }
584
+ }
585
+ }
586
+ },
587
+ },
588
+ };
477
589
  }
package/src/bun-plugin.ts CHANGED
@@ -35,6 +35,7 @@ function shouldTransformSource(path: string, code: string): boolean {
35
35
 
36
36
  export function sigil(options?: {
37
37
  mode?: "dom" | "ssr" | "hydrate";
38
+ dev?: boolean;
38
39
  }): BunPlugin {
39
40
  return {
40
41
  name: "sigil",
@@ -42,8 +43,8 @@ export function sigil(options?: {
42
43
  const transpiler = new Bun.Transpiler({ loader: "tsx" });
43
44
 
44
45
  // This is required for both linking errors,
45
- // and for a bug in the bundler, which was reported in https://github.com/oven-sh/bun/issues/13897 (2 YEARSS AGO)
46
- // and apparently fixed in https://github.com/oven-sh/bun/pull/15119. It wasnt
46
+ // and for a bug in the bundler, which was reported in https://github.com/oven-sh/bun/issues/13897
47
+ // 2 YEARS AGO and apparently fixed in https://github.com/oven-sh/bun/pull/15119. It wasnt
47
48
  build.onResolve({ filter: /^@sigil-dev\/runtime($|\/)/ }, (args) => {
48
49
  try {
49
50
  const resolved = Bun.resolveSync(args.path, process.cwd());
@@ -66,9 +67,10 @@ export function sigil(options?: {
66
67
  });
67
68
  const res = transformSync(code, {
68
69
  parserOpts: {
70
+ //@ts-expect-error holy shut up
69
71
  plugins: [["typescript", { isTSX: true }], "jsx"],
70
72
  },
71
- plugins: [[sigilPlugin, { hash, mode: options?.mode }]],
73
+ plugins: [[sigilPlugin, { hash, mode: options?.mode, dev: options?.dev }]],
72
74
  filename: path,
73
75
  });
74
76
  let out = transpiler.transformSync(res?.code ?? "");
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { transformAsync, transformSSR } from "./helpers/transform.ts";
3
+
4
+ describe("Async Components", () => {
5
+ test("async default export wraps in __asyncBoundary in DOM mode", () => {
6
+ const out = transformAsync(`
7
+ export async function Page({ data }) {
8
+ return <div>{data}</div>;
9
+ }
10
+ export default Page;
11
+ `);
12
+ expect(out).toContain("__asyncBoundary");
13
+ expect(out).toContain("__Page");
14
+ });
15
+
16
+ test("loading export passed to __asyncBoundary", () => {
17
+ const out = transformAsync(`
18
+ export async function Page({ data }) {
19
+ return <div>{data}</div>;
20
+ }
21
+ export default Page;
22
+ export function loading() {
23
+ return <div />;
24
+ }
25
+ `);
26
+ expect(out).toContain("loading");
27
+ expect(out).not.toContain("undefined, undefined");
28
+ });
29
+
30
+ test("no loading or error yields undefined args", () => {
31
+ const out = transformAsync(`
32
+ export async function Page({ data }) {
33
+ return <div>{data}</div>;
34
+ }
35
+ export default Page;
36
+ `);
37
+ expect(out).toContain("undefined, undefined");
38
+ });
39
+
40
+ test("SSR mode leaves async function untouched", () => {
41
+ const out = transformSSR(`
42
+ export async function Page({ data }) {
43
+ return <div>{data}</div>;
44
+ }
45
+ export default Page;
46
+ `);
47
+ expect(out).not.toContain("__asyncBoundary");
48
+ expect(out).not.toContain("__Page");
49
+ });
50
+
51
+ test("non-async default is unchanged", () => {
52
+ const out = transformAsync(`
53
+ export default function Page({ data }) {
54
+ return <div>{data}</div>;
55
+ }
56
+ `);
57
+ expect(out).not.toContain("__asyncBoundary");
58
+ });
59
+ });
@@ -24,3 +24,25 @@ export function transformSSR(code: string) {
24
24
  })?.code ?? ""
25
25
  );
26
26
  }
27
+ export function transformDOM(code: string, hash?: string) {
28
+ return (
29
+ transformSync(code, {
30
+ plugins: [
31
+ "@babel/plugin-syntax-jsx",
32
+ [sigilPlugin, { mode: "dom", hash }],
33
+ ],
34
+ filename: "test.tsx",
35
+ })?.code ?? ""
36
+ );
37
+ }
38
+ export function transformAsync(code: string) {
39
+ return (
40
+ transformSync(code, {
41
+ plugins: [
42
+ "@babel/plugin-syntax-jsx",
43
+ [sigilPlugin, { mode: "dom" }],
44
+ ],
45
+ filename: "test.tsx",
46
+ })?.code ?? ""
47
+ );
48
+ }