@sigil-dev/compiler 0.9.4 → 0.9.6

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.
Files changed (40) hide show
  1. package/index.ts +4 -4
  2. package/jsr.json +8 -8
  3. package/package.json +2 -1
  4. package/src/babel/handlers/dom/derived.ts +62 -62
  5. package/src/babel/handlers/dom/effect.ts +15 -15
  6. package/src/babel/handlers/dom/globals.ts +105 -105
  7. package/src/babel/handlers/dom/state.ts +107 -107
  8. package/src/babel/handlers/ssr/derived.ts +27 -27
  9. package/src/babel/handlers/ssr/state.ts +11 -11
  10. package/src/babel/index.ts +9 -0
  11. package/src/babel/jsx/anchor-mount.ts +24 -1
  12. package/src/babel/jsx/children.ts +53 -69
  13. package/src/babel/jsx/element.ts +76 -50
  14. package/src/babel/jsx/fragment.ts +8 -31
  15. package/src/babel/jsx/index.ts +2 -2
  16. package/src/babel/jsx/keyed-list.ts +309 -309
  17. package/src/babel/jsx/ssr.ts +351 -324
  18. package/src/babel/jsx/text-node.ts +192 -192
  19. package/src/babel/jsx/utils.ts +179 -179
  20. package/src/babel/util/bind.ts +139 -139
  21. package/src/babel/util/css.ts +151 -151
  22. package/src/babel/util/dead-code.ts +28 -28
  23. package/src/babel/util/helpers.ts +46 -46
  24. package/src/babel/util/magic.ts +11 -11
  25. package/src/bun-plugin.ts +92 -92
  26. package/src/vite/index.ts +58 -58
  27. package/test/async-components.test.ts +59 -59
  28. package/test/components.test.ts +129 -129
  29. package/test/containsSignal.test.ts +288 -288
  30. package/test/fragments.test.ts +90 -90
  31. package/test/helpers/transform.ts +45 -45
  32. package/test/hydration.test.ts +130 -1
  33. package/test/jsx.test.ts +227 -225
  34. package/test/keyed-lists.test.ts +126 -126
  35. package/test/reactivity.test.ts +225 -225
  36. package/test/scoped-css.test.ts +129 -129
  37. package/test/ssr.test.ts +130 -130
  38. package/test/style-object.test.ts +13 -0
  39. package/test/tree-shaking.test.ts +78 -78
  40. package/tsconfig.json +30 -30
@@ -1,27 +1,27 @@
1
- import { type NodePath, types as t } from "@babel/core";
2
-
3
- export function handleDerivedSSR(
4
- declaration: NodePath<t.VariableDeclaration>,
5
- declarator: NodePath<t.VariableDeclarator>,
6
- init: NodePath<t.CallExpression>,
7
- _signals?: Set<string>, // SSR has no reactivity, intentionally unused
8
- ): void {
9
- const { id } = declarator.node;
10
- if (!t.isIdentifier(id))
11
- throw new Error("Destructured $derived not supported");
12
-
13
- // Replace $derived(expr) with () => expr (lazy evaluation)
14
- init.replaceWith(
15
- t.arrowFunctionExpression([], init.node.arguments[0] as t.Expression),
16
- );
17
- declaration.node.kind = "const";
18
-
19
- // Replace all references to this variable with () calls
20
- const binding = declaration.scope.getBinding(id.name);
21
- if (!binding) return;
22
- for (const ref of [...binding.referencePaths]) {
23
- if (!ref.isIdentifier()) continue;
24
- if (ref.parentPath?.isUpdateExpression()) continue;
25
- ref.replaceWith(t.callExpression(ref.node, []));
26
- }
27
- }
1
+ import { type NodePath, types as t } from "@babel/core";
2
+
3
+ export function handleDerivedSSR(
4
+ declaration: NodePath<t.VariableDeclaration>,
5
+ declarator: NodePath<t.VariableDeclarator>,
6
+ init: NodePath<t.CallExpression>,
7
+ _signals?: Set<string>, // SSR has no reactivity, intentionally unused
8
+ ): void {
9
+ const { id } = declarator.node;
10
+ if (!t.isIdentifier(id))
11
+ throw new Error("Destructured $derived not supported");
12
+
13
+ // Replace $derived(expr) with () => expr (lazy evaluation)
14
+ init.replaceWith(
15
+ t.arrowFunctionExpression([], init.node.arguments[0] as t.Expression),
16
+ );
17
+ declaration.node.kind = "const";
18
+
19
+ // Replace all references to this variable with () calls
20
+ const binding = declaration.scope.getBinding(id.name);
21
+ if (!binding) return;
22
+ for (const ref of [...binding.referencePaths]) {
23
+ if (!ref.isIdentifier()) continue;
24
+ if (ref.parentPath?.isUpdateExpression()) continue;
25
+ ref.replaceWith(t.callExpression(ref.node, []));
26
+ }
27
+ }
@@ -1,11 +1,11 @@
1
- import type { NodePath, types as t } from "@babel/core";
2
-
3
- export function handleStateSSR(
4
- declaration: NodePath<t.VariableDeclaration>,
5
- _declarator: NodePath<t.VariableDeclarator>,
6
- init: NodePath<t.CallExpression>,
7
- _signals?: Set<string>, // SSR has no reactivity. Left in for future reference
8
- ): void {
9
- init.replaceWith(init.node.arguments[0] as t.Expression);
10
- declaration.node.kind = "let";
11
- }
1
+ import type { NodePath, types as t } from "@babel/core";
2
+
3
+ export function handleStateSSR(
4
+ declaration: NodePath<t.VariableDeclaration>,
5
+ _declarator: NodePath<t.VariableDeclarator>,
6
+ init: NodePath<t.CallExpression>,
7
+ _signals?: Set<string>, // SSR has no reactivity. Left in for future reference
8
+ ): void {
9
+ init.replaceWith(init.node.arguments[0] as t.Expression);
10
+ declaration.node.kind = "let";
11
+ }
@@ -25,6 +25,14 @@ const SSR_HELPERS = template.statements.ast(`
25
25
  if (v?.[__SAFE]) return v.v;
26
26
  if (Array.isArray(v)) return v.map(x => x?.[__SAFE] ? x.v : __e(x)).join('');
27
27
  if (v === false || v === null || v === undefined) return '';
28
+ if (typeof v === 'object' && v.constructor === Object) {
29
+ const UNITLESS = new Set(['animationIterationCount','borderImageOutset','borderImageSlice','borderImageWidth','boxFlex','boxFlexGroup','boxSnapAfterPatch','boxSupress','columnCount','columnFlex','columnFlexGrow','columns','flexGrow','flexPositive','flexShrink','flexNegative','fontWeight','gridArea','gridRow','gridRowEnd','gridRowSpan','gridRowStart','gridColumn','gridColumnEnd','gridColumnSpan','gridColumnStart','lineClamp','lineHeight','opacity','order','orphans','tabSize','widows','zIndex','zoom','fillOpacity','floodOpacity','stopOpacity','strokeDasharray','strokeDashoffset','strokeMiterlimit','strokeOpacity','strokeWidth']);
30
+ return Object.entries(v).map(([k, val]) => {
31
+ const prop = k.replace(/([A-Z])/g, '-$1').toLowerCase();
32
+ if (typeof val === 'number' && !UNITLESS.has(k)) return prop + ':' + val + 'px';
33
+ return prop + ':' + val;
34
+ }).join(';');
35
+ }
28
36
  return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
29
37
  };
30
38
  `);
@@ -174,6 +182,7 @@ export default function sigilPlugin(): PluginObject {
174
182
  "claimText",
175
183
  "claimComment",
176
184
  "hydrateKeyedList",
185
+ "hydrateSlot",
177
186
  "insert",
178
187
  "applyDirective",
179
188
  "getHydrationNodes",
@@ -178,8 +178,24 @@ export function buildAnchorMount(
178
178
  );
179
179
  }
180
180
 
181
+ // typeof x === "function" ? x() : x — hydrate-mode component children are
182
+ // thunks, so a dynamic slot must call them instead of stringifying them.
183
+ const unwrapThunk = (name: string): t.Expression =>
184
+ t.conditionalExpression(
185
+ t.binaryExpression(
186
+ "===",
187
+ t.unaryExpression("typeof", t.identifier(name)),
188
+ t.stringLiteral("function"),
189
+ ),
190
+ t.callExpression(t.identifier(name), []),
191
+ t.identifier(name),
192
+ );
193
+
181
194
  // function _mountFn(_v, _a) { ... }
182
195
  const mountBody = t.blockStatement([
196
+ t.expressionStatement(
197
+ t.assignmentExpression("=", t.identifier("_v"), unwrapThunk("_v")),
198
+ ),
183
199
  t.variableDeclaration("const", [
184
200
  t.variableDeclarator(t.identifier("_nodes"), t.arrayExpression([])),
185
201
  ]),
@@ -190,11 +206,18 @@ export function buildAnchorMount(
190
206
  ),
191
207
  t.blockStatement([
192
208
  t.forOfStatement(
193
- t.variableDeclaration("const", [
209
+ t.variableDeclaration("let", [
194
210
  t.variableDeclarator(t.identifier("_item")),
195
211
  ]),
196
212
  t.identifier("_v"),
197
213
  t.blockStatement([
214
+ t.expressionStatement(
215
+ t.assignmentExpression(
216
+ "=",
217
+ t.identifier("_item"),
218
+ unwrapThunk("_item"),
219
+ ),
220
+ ),
198
221
  t.variableDeclaration("const", [
199
222
  t.variableDeclarator(
200
223
  t.identifier("_n"),
@@ -5,6 +5,13 @@ import { processFragment } from "./fragment";
5
5
  /**
6
6
  * Collect children from a JSX element into a children expression.
7
7
  * Returns null if no meaningful children exist.
8
+ *
9
+ * DOM mode evaluates each JSX child immediately — the component receives real
10
+ * nodes. Hydrate mode emits thunks instead: a component's children live inside
11
+ * *its* root element, a subtree the call site cannot see, so they must claim
12
+ * from the pool that `hydrateSlot` pushes at the `{children}` site rather than
13
+ * from the caller's pool. Evaluating them eagerly here made them steal the
14
+ * component's own SSR root and re-render it (double render).
8
15
  */
9
16
  export function collectChildren(
10
17
  node: t.JSXElement,
@@ -17,95 +24,72 @@ export function collectChildren(
17
24
  ): t.Expression | null {
18
25
  const childrenElements: t.Expression[] = [];
19
26
  let hasOnlyText = true;
27
+ // Deferred children open their own scope from the hydration stack.
28
+ const childNodesVar = hydrate ? "__nodes" : nodesVar;
20
29
 
21
30
  for (const child of node.children) {
22
31
  if (t.isJSXText(child)) {
23
32
  const text = child.value.trim();
24
33
  if (!text) continue;
25
34
  childrenElements.push(t.stringLiteral(text));
26
- } else if (t.isJSXElement(child) || t.isJSXFragment(child)) {
27
- hasOnlyText = false;
28
- const childStatements: t.Statement[] = [];
29
- let childVar: string;
30
- if (t.isJSXElement(child)) {
31
- childVar = processElement(
32
- child,
35
+ continue;
36
+ }
37
+
38
+ let jsx: t.JSXElement | t.JSXFragment | null = null;
39
+ if (t.isJSXElement(child) || t.isJSXFragment(child)) {
40
+ jsx = child;
41
+ } else if (t.isJSXExpressionContainer(child)) {
42
+ const expr = child.expression;
43
+ if (t.isJSXEmptyExpression(expr)) continue;
44
+ if (t.isJSXElement(expr) || t.isJSXFragment(expr)) {
45
+ jsx = expr;
46
+ } else {
47
+ // Plain expression child — pass the value straight through.
48
+ hasOnlyText = false;
49
+ childrenElements.push(expr);
50
+ continue;
51
+ }
52
+ } else {
53
+ continue;
54
+ }
55
+
56
+ hasOnlyText = false;
57
+ const childStatements: t.Statement[] = [];
58
+ const childVar = t.isJSXElement(jsx)
59
+ ? processElement(
60
+ jsx,
33
61
  childStatements,
34
62
  genId,
35
63
  signals,
36
64
  hash,
37
65
  hydrate,
38
- nodesVar,
39
- );
40
- } else {
41
- childVar = processFragment(
42
- child,
66
+ childNodesVar,
67
+ )
68
+ : processFragment(
69
+ jsx,
43
70
  childStatements,
44
71
  genId,
45
72
  signals,
46
73
  hash,
47
74
  hydrate,
48
- nodesVar,
75
+ childNodesVar,
49
76
  );
50
- }
51
- // Build an IIFE from the child statements
52
- childrenElements.push(
53
- t.callExpression(
54
- t.arrowFunctionExpression(
55
- [],
56
- t.blockStatement([
57
- ...childStatements,
58
- t.returnStatement(t.identifier(childVar)),
59
- ]),
77
+
78
+ const body: t.Statement[] = [];
79
+ if (hydrate) {
80
+ // const __nodes = getHydrationNodes() — resolved when the thunk runs
81
+ body.push(
82
+ t.variableDeclaration("const", [
83
+ t.variableDeclarator(
84
+ t.identifier("__nodes"),
85
+ t.callExpression(t.identifier("getHydrationNodes"), []),
60
86
  ),
61
- [],
62
- ),
87
+ ]),
63
88
  );
64
- } else if (t.isJSXExpressionContainer(child)) {
65
- const expr = child.expression as t.Expression;
66
- if (t.isJSXElement(expr) || t.isJSXFragment(expr)) {
67
- hasOnlyText = false;
68
- const childStatements: t.Statement[] = [];
69
- let childVar: string;
70
- if (t.isJSXElement(expr)) {
71
- childVar = processElement(
72
- expr,
73
- childStatements,
74
- genId,
75
- signals,
76
- hash,
77
- hydrate,
78
- nodesVar,
79
- );
80
- } else {
81
- childVar = processFragment(
82
- expr,
83
- childStatements,
84
- genId,
85
- signals,
86
- hash,
87
- hydrate,
88
- nodesVar,
89
- );
90
- }
91
- childrenElements.push(
92
- t.callExpression(
93
- t.arrowFunctionExpression(
94
- [],
95
- t.blockStatement([
96
- ...childStatements,
97
- t.returnStatement(t.identifier(childVar)),
98
- ]),
99
- ),
100
- [],
101
- ),
102
- );
103
- } else {
104
- hasOnlyText = false;
105
- // For dynamic expressions, just pass the expression directly
106
- childrenElements.push(expr);
107
- }
108
89
  }
90
+ body.push(...childStatements, t.returnStatement(t.identifier(childVar)));
91
+ const thunk = t.arrowFunctionExpression([], t.blockStatement(body));
92
+ childrenElements.push(hydrate ? thunk : t.callExpression(thunk, []));
109
93
  }
110
94
 
111
95
  if (childrenElements.length === 0) return null;
@@ -35,6 +35,27 @@ function attrAccess(varName: string, attrName: string): t.MemberExpression {
35
35
  );
36
36
  }
37
37
 
38
+ /**
39
+ * Dashed names (data-*, aria-*, custom attributes) have no IDL property —
40
+ * `el["data-x"] = v` sets a bare JS property the DOM never reflects, so the
41
+ * attribute is invisible to selectors, getAttribute, and CSS. They must go
42
+ * through setAttribute.
43
+ */
44
+ function isDashedAttr(attrName: string): boolean {
45
+ return attrName.includes("-");
46
+ }
47
+
48
+ function setAttrCall(
49
+ varName: string,
50
+ attrName: string,
51
+ value: t.Expression,
52
+ ): t.CallExpression {
53
+ return t.callExpression(
54
+ t.memberExpression(t.identifier(varName), t.identifier("setAttribute")),
55
+ [t.stringLiteral(attrName), value],
56
+ );
57
+ }
58
+
38
59
  /**
39
60
  * Build an object property key (identifier for valid names, string literal for dashed).
40
61
  */
@@ -256,9 +277,7 @@ export function processElement(
256
277
  t.callExpression(t.identifier("mountTransition"), [
257
278
  t.identifier(varName),
258
279
  expr,
259
- t.stringLiteral(
260
- attrNamespace as "transition" | "in" | "out",
261
- ),
280
+ t.stringLiteral(attrNamespace as "transition" | "in" | "out"),
262
281
  ]),
263
282
  ),
264
283
  );
@@ -480,14 +499,35 @@ export function processElement(
480
499
  `Hint: change to a $state() call.`,
481
500
  );
482
501
  }
502
+ } else if (attr.value === null) {
503
+ // bare attribute (<input disabled>, <div data-beacon>)
504
+ if (isDashedAttr(realAttr)) {
505
+ statements.push(
506
+ t.expressionStatement(
507
+ setAttrCall(varName, realAttr, t.stringLiteral("")),
508
+ ),
509
+ );
510
+ } else {
511
+ statements.push(
512
+ t.expressionStatement(
513
+ t.assignmentExpression(
514
+ "=",
515
+ attrAccess(varName, realAttr),
516
+ t.booleanLiteral(true),
517
+ ),
518
+ ),
519
+ );
520
+ }
483
521
  } else if (t.isStringLiteral(attr.value)) {
484
522
  statements.push(
485
523
  t.expressionStatement(
486
- t.assignmentExpression(
487
- "=",
488
- attrAccess(varName, realAttr),
489
- attr.value,
490
- ),
524
+ isDashedAttr(realAttr)
525
+ ? setAttrCall(varName, realAttr, t.stringLiteral(attr.value.value))
526
+ : t.assignmentExpression(
527
+ "=",
528
+ attrAccess(varName, realAttr),
529
+ attr.value,
530
+ ),
491
531
  ),
492
532
  );
493
533
  } else if (t.isJSXExpressionContainer(attr.value)) {
@@ -571,7 +611,7 @@ export function processElement(
571
611
  ),
572
612
  );
573
613
  } else {
574
- // createEffect(() => varName.realAttr = expr)
614
+ // createEffect(() => setAttribute/prop = expr)
575
615
  statements.push(
576
616
  t.expressionStatement(
577
617
  t.callExpression(t.identifier("createEffect"), [
@@ -579,11 +619,13 @@ export function processElement(
579
619
  [],
580
620
  t.blockStatement([
581
621
  t.expressionStatement(
582
- t.assignmentExpression(
583
- "=",
584
- attrAccess(varName, realAttr),
585
- expr,
586
- ),
622
+ isDashedAttr(realAttr)
623
+ ? setAttrCall(varName, realAttr, expr)
624
+ : t.assignmentExpression(
625
+ "=",
626
+ attrAccess(varName, realAttr),
627
+ expr,
628
+ ),
587
629
  ),
588
630
  ]),
589
631
  ),
@@ -592,10 +634,16 @@ export function processElement(
592
634
  );
593
635
  }
594
636
  } else {
595
- // varName.realAttr = expr
637
+ // setAttribute/varName.realAttr = expr
596
638
  statements.push(
597
639
  t.expressionStatement(
598
- t.assignmentExpression("=", attrAccess(varName, realAttr), expr),
640
+ isDashedAttr(realAttr)
641
+ ? setAttrCall(varName, realAttr, expr)
642
+ : t.assignmentExpression(
643
+ "=",
644
+ attrAccess(varName, realAttr),
645
+ expr,
646
+ ),
599
647
  ),
600
648
  );
601
649
  }
@@ -795,41 +843,19 @@ export function processElement(
795
843
  } else {
796
844
  // non-reactive: set once
797
845
  if (hydrate) {
798
- const claimCommentArgs: t.Expression[] = [
799
- t.identifier(childNodesVar!),
800
- t.stringLiteral("g"),
801
- ];
802
- if (varName) claimCommentArgs.push(t.identifier(varName));
803
- const poolLen = t.memberExpression(
804
- t.identifier(childNodesVar!),
805
- t.identifier("length"),
806
- );
807
- // Pool has elements (initial load): SSR content already in DOM, just consume delimiters
808
- const claimG = t.expressionStatement(
809
- t.callExpression(t.identifier("claimComment"), claimCommentArgs),
810
- );
811
- const claimSlashG = (() => {
812
- const args: t.Expression[] = [
813
- t.identifier(childNodesVar!),
814
- t.stringLiteral("/g"),
815
- ];
816
- if (varName) args.push(t.identifier(varName));
817
- return t.expressionStatement(
818
- t.callExpression(t.identifier("claimComment"), args),
819
- );
820
- })();
821
- // Pool empty (SPA nav): create and insert fresh elements
822
- const insertCall = t.expressionStatement(
823
- t.callExpression(t.identifier("insert"), [
824
- t.identifier(varName),
825
- expr,
826
- ]),
827
- );
846
+ // hydrateSlot consumes the <!--g-->…<!--/g--> run and runs the
847
+ // value against the content SSR put inside it, so both deferred
848
+ // component children and inline JSX (`{list.map(…)}`) claim that
849
+ // subtree. The value must stay lazy: evaluating it as an argument
850
+ // would claim from the enclosing pool instead. Empty pool
851
+ // (SPA nav) falls back to a fresh insert.
828
852
  statements.push(
829
- t.ifStatement(
830
- t.binaryExpression(">", poolLen, t.numericLiteral(0)),
831
- t.blockStatement([claimG, claimSlashG]),
832
- t.blockStatement([insertCall]),
853
+ t.expressionStatement(
854
+ t.callExpression(t.identifier("hydrateSlot"), [
855
+ t.identifier(childNodesVar!),
856
+ t.identifier(varName),
857
+ t.arrowFunctionExpression([], expr),
858
+ ]),
833
859
  ),
834
860
  );
835
861
  } else {
@@ -217,41 +217,18 @@ export function processFragment(
217
217
  }
218
218
  } else {
219
219
  if (hydrate) {
220
- // Non-reactive expression in hydrate fragment
221
- const claimCommentArgs: t.Expression[] = [
222
- t.identifier(currentNodesVar),
223
- t.stringLiteral("g"),
224
- t.identifier(effectiveParent),
225
- ];
226
- const poolLen = t.memberExpression(
227
- t.identifier(currentNodesVar),
228
- t.identifier("length"),
229
- );
230
- const claimG = t.expressionStatement(
231
- t.callExpression(t.identifier("claimComment"), claimCommentArgs),
232
- );
233
- const claimSlashG = t.expressionStatement(
234
- t.callExpression(t.identifier("claimComment"), [
235
- t.identifier(currentNodesVar),
236
- t.stringLiteral("/g"),
237
- t.identifier(effectiveParent),
238
- ]),
239
- );
240
- // SPA navigation: pool empty, insert into effectiveParent
241
- const elseBranch = t.blockStatement([
220
+ // Non-reactive expression in hydrate fragment: consume the
221
+ // <!--g-->…<!--/g--> run and hydrate its content. Kept lazy so
222
+ // the value claims from the slot, not the enclosing pool;
223
+ // empty pool (SPA nav) inserts fresh.
224
+ statements.push(
242
225
  t.expressionStatement(
243
- t.callExpression(t.identifier("insert"), [
226
+ t.callExpression(t.identifier("hydrateSlot"), [
227
+ t.identifier(currentNodesVar),
244
228
  t.identifier(effectiveParent),
245
- expr,
229
+ t.arrowFunctionExpression([], expr),
246
230
  ]),
247
231
  ),
248
- ]);
249
- statements.push(
250
- t.ifStatement(
251
- t.binaryExpression(">", poolLen, t.numericLiteral(0)),
252
- t.blockStatement([claimG, claimSlashG]),
253
- elseBranch,
254
- ),
255
232
  );
256
233
  } else {
257
234
  statements.push(
@@ -1,2 +1,2 @@
1
- export { processElement } from "./element";
2
- export { processFragment } from "./fragment";
1
+ export { processElement } from "./element";
2
+ export { processFragment } from "./fragment";