@sigil-dev/compiler 0.9.10 → 0.9.12

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.
@@ -6,6 +6,7 @@ import {
6
6
  isGlobalComponent,
7
7
  } from "../handlers/dom/globals";
8
8
  import { buildBind, buildProxyBind } from "../util/bind";
9
+ import { isPropRead } from "../util/props.ts";
9
10
  import { buildAnchorMount } from "./anchor-mount";
10
11
  import { collectChildren } from "./children";
11
12
  import { processFragment } from "./fragment";
@@ -16,7 +17,9 @@ import {
16
17
  buildHydrationScope,
17
18
  containsSignal,
18
19
  getCreateElement,
20
+ hasJSX,
19
21
  isPrimitive,
22
+ jsxText,
20
23
  } from "./utils";
21
24
 
22
25
  /**
@@ -59,6 +62,61 @@ function setPropCall(
59
62
  ]);
60
63
  }
61
64
 
65
+ function isClaimedCall(varName: string): t.CallExpression {
66
+ return t.callExpression(t.identifier("isClaimed"), [t.identifier(varName)]);
67
+ }
68
+
69
+ function effect(body: t.Statement[]): t.ExpressionStatement {
70
+ return t.expressionStatement(
71
+ t.callExpression(t.identifier("createEffect"), [
72
+ t.arrowFunctionExpression([], t.blockStatement(body)),
73
+ ]),
74
+ );
75
+ }
76
+
77
+ /** Emit fresh-element-only writes; hydration guards them with isClaimed(). */
78
+ function pushInit(
79
+ varName: string,
80
+ init: t.Statement[],
81
+ statements: t.Statement[],
82
+ hydrate?: boolean,
83
+ ): void {
84
+ if (!init.length) return;
85
+ if (hydrate) {
86
+ statements.push(
87
+ t.ifStatement(
88
+ t.unaryExpression("!", isClaimedCall(varName)),
89
+ t.blockStatement(init),
90
+ ),
91
+ );
92
+ } else {
93
+ statements.push(...init);
94
+ }
95
+ }
96
+
97
+ /** A <script>'s children as one string expression, or null if it has none. */
98
+ function scriptText(node: t.JSXElement): t.Expression | null {
99
+ const parts: t.Expression[] = [];
100
+ for (const child of node.children) {
101
+ if (t.isJSXText(child)) {
102
+ const text = jsxText(child);
103
+ if (text !== null) parts.push(t.stringLiteral(text));
104
+ } else if (
105
+ t.isJSXExpressionContainer(child) &&
106
+ !t.isJSXEmptyExpression(child.expression)
107
+ ) {
108
+ parts.push(
109
+ t.logicalExpression("??", child.expression, t.stringLiteral("")),
110
+ );
111
+ }
112
+ }
113
+ if (!parts.length) return null;
114
+ return parts.reduce(
115
+ (a, b) => t.binaryExpression("+", a, b),
116
+ t.stringLiteral(""),
117
+ );
118
+ }
119
+
62
120
  /**
63
121
  * Build an object property key (identifier for valid names, string literal for dashed).
64
122
  */
@@ -123,13 +181,44 @@ export function processElement(
123
181
  if (!t.isJSXAttribute(attr)) continue;
124
182
  const attrName = (attr.name as t.JSXIdentifier).name;
125
183
 
126
- if (t.isStringLiteral(attr.value)) {
184
+ if (attr.value === null) {
185
+ // <Comp disabled /> → { disabled: true }
186
+ propsObj.properties.push(
187
+ t.objectProperty(attrKey(attrName), t.booleanLiteral(true)),
188
+ );
189
+ } else if (t.isStringLiteral(attr.value)) {
127
190
  propsObj.properties.push(
128
191
  t.objectProperty(attrKey(attrName), attr.value),
129
192
  );
130
193
  } else if (t.isJSXExpressionContainer(attr.value)) {
131
194
  const expr = attr.value.expression as t.Expression;
132
- propsObj.properties.push(t.objectProperty(attrKey(attrName), expr));
195
+ // JSX in a prop (icon={<Icon />}, actions={<>…</>}) belongs
196
+ // wherever the component puts it, inside its own root. Built
197
+ // now, during hydration, it would claim from the caller's pool
198
+ // and could take the component's root element. Defer it like
199
+ // children: the component's {prop} slot runs the thunk against
200
+ // the right nodes.
201
+ if (hydrate && hasJSX(expr)) {
202
+ propsObj.properties.push(
203
+ t.objectProperty(
204
+ attrKey(attrName),
205
+ t.arrowFunctionExpression([], expr),
206
+ ),
207
+ );
208
+ } else if (containsSignal(expr, signals)) {
209
+ // a prop that reads state is passed as a getter, so the
210
+ // component reads the current value wherever it uses it
211
+ propsObj.properties.push(
212
+ t.objectMethod(
213
+ "get",
214
+ attrKey(attrName),
215
+ [],
216
+ t.blockStatement([t.returnStatement(expr)]),
217
+ ),
218
+ );
219
+ } else {
220
+ propsObj.properties.push(t.objectProperty(attrKey(attrName), expr));
221
+ }
133
222
  }
134
223
  }
135
224
 
@@ -230,18 +319,24 @@ export function processElement(
230
319
  buildHydrationScope(varName, statements, childNodesVar);
231
320
  }
232
321
 
322
+ // Attribute writes that only matter on a fresh element. When hydration
323
+ // claims server HTML they already happened there, and redoing them would
324
+ // wipe whatever a page script changed in the meantime.
325
+ const init: t.Statement[] = [];
326
+ // <select value> needs its options to exist first.
327
+ const initAfter: t.Statement[] = [];
328
+ const liveAfter: t.Statement[] = [];
329
+
233
330
  // attributes
234
331
  const seenAttrs = new Set<string>();
235
332
  for (const attr of node.openingElement.attributes) {
236
333
  if (t.isJSXSpreadAttribute(attr)) {
237
- // {…obj} — spread onto native element
334
+ // {…obj}: listeners always, attributes unless hydration claimed
335
+ // the element (then the server already rendered them)
336
+ const args: t.Expression[] = [t.identifier(varName), attr.argument];
337
+ if (hydrate) args.push(t.unaryExpression("!", isClaimedCall(varName)));
238
338
  statements.push(
239
- t.expressionStatement(
240
- t.callExpression(t.identifier("Object.assign"), [
241
- t.identifier(varName),
242
- attr.argument as t.Expression,
243
- ]),
244
- ),
339
+ t.expressionStatement(t.callExpression(t.identifier("spread"), args)),
245
340
  );
246
341
  continue;
247
342
  }
@@ -256,7 +351,8 @@ export function processElement(
256
351
  // bindValue -> bind:value, bindChecked -> bind:checked
257
352
  if (/^bind[A-Z]/.test(attrName)) {
258
353
  attrNamespace = "bind";
259
- attrName = attrName.slice(4).toLowerCase();
354
+ // bindScrollTop -> bind:scrollTop: only the first letter lowers
355
+ attrName = attrName.charAt(4).toLowerCase() + attrName.slice(5);
260
356
  }
261
357
  }
262
358
  const realAttr = ATTR_MAP[attrName] ?? attrName;
@@ -491,9 +587,19 @@ export function processElement(
491
587
  ).name,
492
588
  );
493
589
 
590
+ // bind:checked={item.done} in a list row, or {book.read} where `book`
591
+ // is a prop: the chain is rooted at a row item or a props object,
592
+ // and what it ends in is a property of a $state proxy
593
+ let root: t.Node = signal;
594
+ while (t.isMemberExpression(root)) root = root.object;
595
+ const isMemberOfItem =
596
+ t.isMemberExpression(signal) &&
597
+ t.isIdentifier(root) &&
598
+ signals.has(root.name);
599
+
494
600
  if (isTopLevelSignal) {
495
601
  buildBind(varName, bindProp, attr, statements);
496
- } else if (isMemberOfSignal) {
602
+ } else if (isMemberOfSignal || isMemberOfItem) {
497
603
  buildProxyBind(varName, bindProp, attr, statements, signal);
498
604
  } else {
499
605
  console.warn(
@@ -504,30 +610,22 @@ export function processElement(
504
610
  }
505
611
  } else if (attr.value === null) {
506
612
  // bare attribute (<input disabled>, <div data-beacon>)
507
- if (isDashedAttr(realAttr)) {
508
- statements.push(
509
- t.expressionStatement(
510
- setAttrCall(varName, realAttr, t.stringLiteral("")),
511
- ),
512
- );
513
- } else {
514
- statements.push(
515
- t.expressionStatement(
516
- setPropCall(varName, realAttr, t.booleanLiteral(true)),
517
- ),
518
- );
519
- }
520
- } else if (t.isStringLiteral(attr.value)) {
521
- statements.push(
613
+ init.push(
522
614
  t.expressionStatement(
523
615
  isDashedAttr(realAttr)
524
- ? setAttrCall(varName, realAttr, t.stringLiteral(attr.value.value))
525
- : setPropCall(varName, realAttr, attr.value),
616
+ ? setAttrCall(varName, realAttr, t.stringLiteral(""))
617
+ : setPropCall(varName, realAttr, t.booleanLiteral(true)),
526
618
  ),
527
619
  );
620
+ } else if (t.isStringLiteral(attr.value)) {
621
+ const stmt = t.expressionStatement(
622
+ setPropCall(varName, realAttr, t.stringLiteral(attr.value.value)),
623
+ );
624
+ (tag === "select" && realAttr === "value" ? initAfter : init).push(stmt);
528
625
  } else if (t.isJSXExpressionContainer(attr.value)) {
529
626
  const expr = attr.value.expression as t.Expression;
530
- // style={{ color: "red", fontSize: val }} — per-property assignment
627
+ const afterChildren = tag === "select" && realAttr === "value";
628
+ // style={{ color: "red", fontSize: val }} — per-property writes
531
629
  if (attrName === "style" && t.isObjectExpression(expr)) {
532
630
  for (const prop of expr.properties) {
533
631
  if (!t.isObjectProperty(prop)) continue;
@@ -538,105 +636,68 @@ export function processElement(
538
636
  : null;
539
637
  if (!key) continue;
540
638
  const val = prop.value as t.Expression;
541
- const styleProp = t.memberExpression(
542
- t.memberExpression(t.identifier(varName), t.identifier("style")),
543
- t.identifier(key),
639
+ const write = t.expressionStatement(
640
+ t.callExpression(t.identifier("setStyleProp"), [
641
+ t.identifier(varName),
642
+ t.stringLiteral(key),
643
+ val,
644
+ ]),
544
645
  );
545
646
  if (containsSignal(val, signals)) {
546
- statements.push(
547
- t.expressionStatement(
548
- t.callExpression(t.identifier("createEffect"), [
549
- t.arrowFunctionExpression(
550
- [],
551
- t.blockStatement([
552
- t.expressionStatement(
553
- t.assignmentExpression("=", styleProp, val),
554
- ),
555
- ]),
556
- ),
557
- ]),
558
- ),
559
- );
647
+ statements.push(effect([write]));
560
648
  } else {
561
- statements.push(
562
- t.expressionStatement(
563
- t.assignmentExpression("=", styleProp, val),
564
- ),
565
- );
649
+ init.push(write);
566
650
  }
567
651
  }
568
- // done with this attribute
569
652
  } else if (containsSignal(expr, signals)) {
570
653
  // Dynamic class with scoped CSS: use classList.value to preserve hash
571
654
  if (attrName === "class" && hash) {
572
655
  statements.push(
573
- t.expressionStatement(
574
- t.callExpression(t.identifier("createEffect"), [
575
- t.arrowFunctionExpression(
576
- [],
577
- t.blockStatement([
578
- t.expressionStatement(
579
- t.assignmentExpression(
580
- "=",
581
- t.memberExpression(
582
- t.memberExpression(
583
- t.identifier(varName),
584
- t.identifier("classList"),
585
- ),
586
- t.identifier("value"),
587
- ),
588
- expr,
589
- ),
590
- ),
591
- t.expressionStatement(
592
- t.callExpression(
593
- t.memberExpression(
594
- t.memberExpression(
595
- t.identifier(varName),
596
- t.identifier("classList"),
597
- ),
598
- t.identifier("add"),
599
- ),
600
- [t.stringLiteral(hash)],
601
- ),
656
+ effect([
657
+ t.expressionStatement(
658
+ t.assignmentExpression(
659
+ "=",
660
+ t.memberExpression(
661
+ t.memberExpression(
662
+ t.identifier(varName),
663
+ t.identifier("classList"),
602
664
  ),
603
- ]),
665
+ t.identifier("value"),
666
+ ),
667
+ expr,
604
668
  ),
605
- ]),
606
- ),
607
- );
608
- } else {
609
- // createEffect(() => setAttribute/prop = expr)
610
- statements.push(
611
- t.expressionStatement(
612
- t.callExpression(t.identifier("createEffect"), [
613
- t.arrowFunctionExpression(
614
- [],
615
- t.blockStatement([
616
- t.expressionStatement(
617
- isDashedAttr(realAttr)
618
- ? setAttrCall(varName, realAttr, expr)
619
- : setPropCall(varName, realAttr, expr),
669
+ ),
670
+ t.expressionStatement(
671
+ t.callExpression(
672
+ t.memberExpression(
673
+ t.memberExpression(
674
+ t.identifier(varName),
675
+ t.identifier("classList"),
620
676
  ),
621
- ]),
677
+ t.identifier("add"),
678
+ ),
679
+ [t.stringLiteral(hash)],
622
680
  ),
623
- ]),
624
- ),
681
+ ),
682
+ ]),
625
683
  );
684
+ } else {
685
+ const stmt = effect([
686
+ t.expressionStatement(setPropCall(varName, realAttr, expr)),
687
+ ]);
688
+ (afterChildren ? liveAfter : statements).push(stmt);
626
689
  }
627
690
  } else {
628
- // setAttribute for dashed, runtime setProp (attribute-safe on SVG) otherwise
629
- statements.push(
630
- t.expressionStatement(
631
- isDashedAttr(realAttr)
632
- ? setAttrCall(varName, realAttr, expr)
633
- : setPropCall(varName, realAttr, expr),
634
- ),
691
+ const stmt = t.expressionStatement(
692
+ setPropCall(varName, realAttr, expr),
635
693
  );
694
+ (afterChildren ? initAfter : init).push(stmt);
636
695
  }
637
696
  }
638
697
  }
639
698
 
699
+ pushInit(varName, init, statements, hydrate);
700
+
640
701
  // Add scoped CSS hash to classList (if hash is present)
641
702
  if (hash) {
642
703
  addScopedClass(varName, hash, statements);
@@ -645,6 +706,25 @@ export function processElement(
645
706
  // children. <script> text is already in the server HTML, raw and unmarked
646
707
  // (see ssr.ts), so hydration has nothing to claim there: the marker search
647
708
  // would miss and append a second copy of the code.
709
+ if (hydrate && tag === "script") {
710
+ // ...but SPA navigation creates the element fresh and needs the text.
711
+ const text = scriptText(node);
712
+ if (text) {
713
+ const write = [
714
+ t.expressionStatement(
715
+ t.assignmentExpression(
716
+ "=",
717
+ t.memberExpression(
718
+ t.identifier(varName),
719
+ t.identifier("textContent"),
720
+ ),
721
+ text,
722
+ ),
723
+ ),
724
+ ];
725
+ pushInit(varName, write, statements, hydrate);
726
+ }
727
+ }
648
728
  for (const child of hydrate && tag === "script" ? [] : node.children) {
649
729
  if (t.isJSXElement(child)) {
650
730
  const childVar = processElement(
@@ -689,8 +769,8 @@ export function processElement(
689
769
  );
690
770
  }
691
771
  } else if (t.isJSXText(child)) {
692
- const text = child.value;
693
- if (!text.trim()) continue;
772
+ const text = jsxText(child);
773
+ if (text === null) continue;
694
774
  if (hydrate) {
695
775
  // SSR hydration: text already in DOM from SSR, do nothing.
696
776
  // SPA navigation: childNodesVar pool is empty, create text node.
@@ -713,7 +793,7 @@ export function processElement(
713
793
  t.identifier("document"),
714
794
  t.identifier("createTextNode"),
715
795
  ),
716
- [t.stringLiteral(text.replace(/\s*\n\s*/g, " "))],
796
+ [t.stringLiteral(text)],
717
797
  ),
718
798
  ],
719
799
  ),
@@ -789,6 +869,18 @@ export function processElement(
789
869
  ),
790
870
  );
791
871
  }
872
+ } else if (isPropRead(expr)) {
873
+ // a prop may hold text, nodes or a deferred child, and may be a
874
+ // getter over state: a live slot handles all of them
875
+ statements.push(
876
+ t.expressionStatement(
877
+ t.callExpression(t.identifier("liveSlot"), [
878
+ hydrate ? t.identifier(childNodesVar!) : t.arrayExpression([]),
879
+ t.identifier(varName),
880
+ t.arrowFunctionExpression([], expr),
881
+ ]),
882
+ ),
883
+ );
792
884
  } else if (containsSignal(expr, signals)) {
793
885
  if (isPrimitive(expr)) {
794
886
  // fast path: text node + textContent
@@ -861,5 +953,8 @@ export function processElement(
861
953
  }
862
954
  }
863
955
 
956
+ pushInit(varName, initAfter, statements, hydrate);
957
+ statements.push(...liveAfter);
958
+
864
959
  return varName;
865
960
  }
@@ -1,9 +1,10 @@
1
1
  import { types as t } from "@babel/core";
2
+ import { isPropRead } from "../util/props.ts";
2
3
  import { buildAnchorMount } from "./anchor-mount";
3
4
  import { processElement } from "./element";
4
5
  import { buildKeyedList, findKeyedMapExpr } from "./keyed-list";
5
6
  import { buildTextNode } from "./text-node";
6
- import { containsSignal, isPrimitive } from "./utils";
7
+ import { containsSignal, isPrimitive, jsxText } from "./utils";
7
8
 
8
9
  /**
9
10
  * Process a JSX fragment (<>...</>).
@@ -76,8 +77,8 @@ export function processFragment(
76
77
  );
77
78
  }
78
79
  } else if (t.isJSXText(child)) {
79
- const text = child.value.trim();
80
- if (!text) continue;
80
+ const text = jsxText(child);
81
+ if (text === null) continue;
81
82
  if (hydrate) {
82
83
  // SSR hydration: text already in DOM from SSR, do nothing.
83
84
  // SPA navigation: pool is empty, create text node and append to effectiveParent.
@@ -176,6 +177,17 @@ export function processFragment(
176
177
  ),
177
178
  );
178
179
  }
180
+ } else if (isPropRead(expr)) {
181
+ // see element.ts: a prop may be text, nodes, a thunk, or a getter
182
+ statements.push(
183
+ t.expressionStatement(
184
+ t.callExpression(t.identifier("liveSlot"), [
185
+ hydrate ? t.identifier(currentNodesVar) : t.arrayExpression([]),
186
+ t.identifier(hydrate ? effectiveParent : varName),
187
+ t.arrowFunctionExpression([], expr),
188
+ ]),
189
+ ),
190
+ );
179
191
  } else if (containsSignal(expr, signals)) {
180
192
  if (isPrimitive(expr)) {
181
193
  buildTextNode(
@@ -107,6 +107,14 @@ export function buildKeyedList(
107
107
  keyed.keyExpr ?? t.identifier("_i"),
108
108
  );
109
109
 
110
+ // Inside a row, the item is an element of a $state array, which makes it a
111
+ // reactive proxy: `item.done` must update the row when it changes, and
112
+ // bind:checked={item.done} must write back to it. Treating the item param
113
+ // as a signal gives its reads effects; a primitive item costs one effect
114
+ // that never re-runs.
115
+ const rowSignals = new Set(signals);
116
+ rowSignals.add(keyed.itemParam.name);
117
+
110
118
  // Strip key prop from JSX before processing.
111
119
  // In SSR mode, the Babel plugin already converted key → data-key.
112
120
  // In DOM/hydrate mode, we don't need either.
@@ -139,7 +147,7 @@ export function buildKeyedList(
139
147
  keyed.jsxBody,
140
148
  createNodeStmts,
141
149
  genId,
142
- signals,
150
+ rowSignals,
143
151
  hash,
144
152
  hydrate,
145
153
  nodesVar,
@@ -274,7 +282,7 @@ export function buildKeyedList(
274
282
  keyed.jsxBody,
275
283
  createNodeStmts,
276
284
  genId,
277
- signals,
285
+ rowSignals,
278
286
  hash,
279
287
  );
280
288