@sigil-dev/compiler 0.9.5 → 0.9.7
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/README.md +162 -162
- package/package.json +1 -1
- package/src/babel/index.ts +614 -607
- package/src/babel/jsx/anchor-mount.ts +24 -1
- package/src/babel/jsx/children.ts +53 -69
- package/src/babel/jsx/element.ts +72 -57
- package/src/babel/jsx/fragment.ts +8 -31
- package/src/babel/jsx/ssr.ts +45 -0
- package/test/hydration.test.ts +132 -3
- package/test/jsx.test.ts +10 -5
- package/test/scoped-css.test.ts +2 -2
- package/test/ssr-boolean-attrs.test.ts +56 -0
- package/test/style-object.test.ts +13 -13
|
@@ -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("
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
75
|
+
childNodesVar,
|
|
49
76
|
);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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;
|
package/src/babel/jsx/element.ts
CHANGED
|
@@ -20,19 +20,43 @@ import {
|
|
|
20
20
|
} from "./utils";
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* Dashed names (data-*, aria-*, custom attributes) have no IDL property —
|
|
24
|
+
* `el["data-x"] = v` sets a bare JS property the DOM never reflects, so the
|
|
25
|
+
* attribute is invisible to selectors, getAttribute, and CSS. They must go
|
|
26
|
+
* through setAttribute.
|
|
26
27
|
*/
|
|
27
|
-
function
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
function isDashedAttr(attrName: string): boolean {
|
|
29
|
+
return attrName.includes("-");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function setAttrCall(
|
|
33
|
+
varName: string,
|
|
34
|
+
attrName: string,
|
|
35
|
+
value: t.Expression,
|
|
36
|
+
): t.CallExpression {
|
|
37
|
+
return t.callExpression(
|
|
38
|
+
t.memberExpression(t.identifier(varName), t.identifier("setAttribute")),
|
|
39
|
+
[t.stringLiteral(attrName), value],
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Route a prop write through the runtime setProp() helper. On HTML elements
|
|
45
|
+
* this is today's blind `el[name] = value`; on SVG-namespace elements it
|
|
46
|
+
* goes through setAttribute instead — IDL property assignment throws on real
|
|
47
|
+
* SVG elements (`svg.width = 15` hits a getter-only property), which crashed
|
|
48
|
+
* CSR renders the moment claim() started creating true-namespaced SVG.
|
|
49
|
+
*/
|
|
50
|
+
function setPropCall(
|
|
51
|
+
varName: string,
|
|
52
|
+
attrName: string,
|
|
53
|
+
value: t.Expression,
|
|
54
|
+
): t.CallExpression {
|
|
55
|
+
return t.callExpression(t.identifier("setProp"), [
|
|
32
56
|
t.identifier(varName),
|
|
33
57
|
t.stringLiteral(attrName),
|
|
34
|
-
|
|
35
|
-
);
|
|
58
|
+
value,
|
|
59
|
+
]);
|
|
36
60
|
}
|
|
37
61
|
|
|
38
62
|
/**
|
|
@@ -478,14 +502,27 @@ export function processElement(
|
|
|
478
502
|
`Hint: change to a $state() call.`,
|
|
479
503
|
);
|
|
480
504
|
}
|
|
505
|
+
} else if (attr.value === null) {
|
|
506
|
+
// 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
|
+
}
|
|
481
520
|
} else if (t.isStringLiteral(attr.value)) {
|
|
482
521
|
statements.push(
|
|
483
522
|
t.expressionStatement(
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
attr.value,
|
|
488
|
-
),
|
|
523
|
+
isDashedAttr(realAttr)
|
|
524
|
+
? setAttrCall(varName, realAttr, t.stringLiteral(attr.value.value))
|
|
525
|
+
: setPropCall(varName, realAttr, attr.value),
|
|
489
526
|
),
|
|
490
527
|
);
|
|
491
528
|
} else if (t.isJSXExpressionContainer(attr.value)) {
|
|
@@ -569,7 +606,7 @@ export function processElement(
|
|
|
569
606
|
),
|
|
570
607
|
);
|
|
571
608
|
} else {
|
|
572
|
-
// createEffect(() =>
|
|
609
|
+
// createEffect(() => setAttribute/prop = expr)
|
|
573
610
|
statements.push(
|
|
574
611
|
t.expressionStatement(
|
|
575
612
|
t.callExpression(t.identifier("createEffect"), [
|
|
@@ -577,11 +614,9 @@ export function processElement(
|
|
|
577
614
|
[],
|
|
578
615
|
t.blockStatement([
|
|
579
616
|
t.expressionStatement(
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
expr,
|
|
584
|
-
),
|
|
617
|
+
isDashedAttr(realAttr)
|
|
618
|
+
? setAttrCall(varName, realAttr, expr)
|
|
619
|
+
: setPropCall(varName, realAttr, expr),
|
|
585
620
|
),
|
|
586
621
|
]),
|
|
587
622
|
),
|
|
@@ -590,10 +625,12 @@ export function processElement(
|
|
|
590
625
|
);
|
|
591
626
|
}
|
|
592
627
|
} else {
|
|
593
|
-
//
|
|
628
|
+
// setAttribute for dashed, runtime setProp (attribute-safe on SVG) otherwise
|
|
594
629
|
statements.push(
|
|
595
630
|
t.expressionStatement(
|
|
596
|
-
|
|
631
|
+
isDashedAttr(realAttr)
|
|
632
|
+
? setAttrCall(varName, realAttr, expr)
|
|
633
|
+
: setPropCall(varName, realAttr, expr),
|
|
597
634
|
),
|
|
598
635
|
);
|
|
599
636
|
}
|
|
@@ -793,41 +830,19 @@ export function processElement(
|
|
|
793
830
|
} else {
|
|
794
831
|
// non-reactive: set once
|
|
795
832
|
if (hydrate) {
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
t.identifier(childNodesVar!),
|
|
803
|
-
t.identifier("length"),
|
|
804
|
-
);
|
|
805
|
-
// Pool has elements (initial load): SSR content already in DOM, just consume delimiters
|
|
806
|
-
const claimG = t.expressionStatement(
|
|
807
|
-
t.callExpression(t.identifier("claimComment"), claimCommentArgs),
|
|
808
|
-
);
|
|
809
|
-
const claimSlashG = (() => {
|
|
810
|
-
const args: t.Expression[] = [
|
|
811
|
-
t.identifier(childNodesVar!),
|
|
812
|
-
t.stringLiteral("/g"),
|
|
813
|
-
];
|
|
814
|
-
if (varName) args.push(t.identifier(varName));
|
|
815
|
-
return t.expressionStatement(
|
|
816
|
-
t.callExpression(t.identifier("claimComment"), args),
|
|
817
|
-
);
|
|
818
|
-
})();
|
|
819
|
-
// Pool empty (SPA nav): create and insert fresh elements
|
|
820
|
-
const insertCall = t.expressionStatement(
|
|
821
|
-
t.callExpression(t.identifier("insert"), [
|
|
822
|
-
t.identifier(varName),
|
|
823
|
-
expr,
|
|
824
|
-
]),
|
|
825
|
-
);
|
|
833
|
+
// hydrateSlot consumes the <!--g-->…<!--/g--> run and runs the
|
|
834
|
+
// value against the content SSR put inside it, so both deferred
|
|
835
|
+
// component children and inline JSX (`{list.map(…)}`) claim that
|
|
836
|
+
// subtree. The value must stay lazy: evaluating it as an argument
|
|
837
|
+
// would claim from the enclosing pool instead. Empty pool
|
|
838
|
+
// (SPA nav) falls back to a fresh insert.
|
|
826
839
|
statements.push(
|
|
827
|
-
t.
|
|
828
|
-
t.
|
|
829
|
-
|
|
830
|
-
|
|
840
|
+
t.expressionStatement(
|
|
841
|
+
t.callExpression(t.identifier("hydrateSlot"), [
|
|
842
|
+
t.identifier(childNodesVar!),
|
|
843
|
+
t.identifier(varName),
|
|
844
|
+
t.arrowFunctionExpression([], expr),
|
|
845
|
+
]),
|
|
831
846
|
),
|
|
832
847
|
);
|
|
833
848
|
} 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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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("
|
|
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(
|
package/src/babel/jsx/ssr.ts
CHANGED
|
@@ -2,6 +2,38 @@ import { types as t } from "@babel/core";
|
|
|
2
2
|
import { isGlobalComponent } from "../handlers/dom/globals";
|
|
3
3
|
import { getTagName } from "../util/helpers";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Attributes that are on by presence alone: checked="" and even
|
|
7
|
+
* checked="false" both mean checked, so these can't go through the generic
|
|
8
|
+
* name="value" path.
|
|
9
|
+
*/
|
|
10
|
+
const BOOLEAN_ATTRS = new Set([
|
|
11
|
+
"allowfullscreen",
|
|
12
|
+
"async",
|
|
13
|
+
"autofocus",
|
|
14
|
+
"autoplay",
|
|
15
|
+
"checked",
|
|
16
|
+
"controls",
|
|
17
|
+
"default",
|
|
18
|
+
"defer",
|
|
19
|
+
"disabled",
|
|
20
|
+
"formnovalidate",
|
|
21
|
+
"hidden",
|
|
22
|
+
"inert",
|
|
23
|
+
"ismap",
|
|
24
|
+
"loop",
|
|
25
|
+
"multiple",
|
|
26
|
+
"muted",
|
|
27
|
+
"nomodule",
|
|
28
|
+
"novalidate",
|
|
29
|
+
"open",
|
|
30
|
+
"playsinline",
|
|
31
|
+
"readonly",
|
|
32
|
+
"required",
|
|
33
|
+
"reversed",
|
|
34
|
+
"selected",
|
|
35
|
+
]);
|
|
36
|
+
|
|
5
37
|
const VOID_ELEMENTS = new Set([
|
|
6
38
|
"area",
|
|
7
39
|
"base",
|
|
@@ -160,6 +192,19 @@ export function processElementSSR(
|
|
|
160
192
|
|
|
161
193
|
if (t.isStringLiteral(attr.value)) {
|
|
162
194
|
parts.push(` ${domAttr}="${attr.value.value}"`);
|
|
195
|
+
} else if (
|
|
196
|
+
t.isJSXExpressionContainer(attr.value) &&
|
|
197
|
+
BOOLEAN_ATTRS.has(domAttr.toLowerCase())
|
|
198
|
+
) {
|
|
199
|
+
// __b: true → bare name, false/null/undefined → omitted, anything
|
|
200
|
+
// else keeps its value (hidden="until-found"). The generic path
|
|
201
|
+
// below rendered checked={false} as checked="", i.e. checked.
|
|
202
|
+
parts.push(
|
|
203
|
+
t.callExpression(t.identifier("__b"), [
|
|
204
|
+
t.stringLiteral(domAttr),
|
|
205
|
+
attr.value.expression as t.Expression,
|
|
206
|
+
]),
|
|
207
|
+
);
|
|
163
208
|
} else if (t.isJSXExpressionContainer(attr.value)) {
|
|
164
209
|
const expr = attr.value.expression as t.Expression;
|
|
165
210
|
if (
|
package/test/hydration.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { transformSync } from "@babel/core";
|
|
3
|
+
import * as runtime from "../../runtime/index.ts";
|
|
3
4
|
import sigilPlugin from "../src/babel/index.ts";
|
|
4
5
|
|
|
5
6
|
function transform(
|
|
@@ -16,6 +17,23 @@ function transform(
|
|
|
16
17
|
return result!.code!;
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Evaluate compiled output and return its exported `App`. SSR output carries
|
|
22
|
+
* its own `__h`/`__e` helpers; hydrate output imports from the runtime, which
|
|
23
|
+
* is injected as plain parameters instead.
|
|
24
|
+
*/
|
|
25
|
+
function evalApp(code: string): () => unknown {
|
|
26
|
+
const body = code
|
|
27
|
+
.replace(/import\s*\{[^}]*\}\s*from\s*["']@sigil-dev\/runtime["'];?/g, "")
|
|
28
|
+
.replace(/^export\s+/gm, "");
|
|
29
|
+
const factory = new Function(
|
|
30
|
+
"document",
|
|
31
|
+
...Object.keys(runtime),
|
|
32
|
+
`${body};\nreturn App;`,
|
|
33
|
+
);
|
|
34
|
+
return factory(globalThis.document, ...Object.values(runtime));
|
|
35
|
+
}
|
|
36
|
+
|
|
19
37
|
describe("Hydration", () => {
|
|
20
38
|
test("element uses claim instead of createElement", () => {
|
|
21
39
|
const code = transform(`const el = <div></div>`, { mode: "hydrate" });
|
|
@@ -61,8 +79,8 @@ describe("Hydration", () => {
|
|
|
61
79
|
mode: "hydrate",
|
|
62
80
|
});
|
|
63
81
|
expect(code).toContain("claim(");
|
|
64
|
-
expect(code).toContain('className
|
|
65
|
-
expect(code).toContain('id
|
|
82
|
+
expect(code).toContain('setProp(_el0, "className", "foo")');
|
|
83
|
+
expect(code).toContain('setProp(_el0, "id", "bar")');
|
|
66
84
|
});
|
|
67
85
|
|
|
68
86
|
test("event handlers still attached in hydrate mode", () => {
|
|
@@ -112,7 +130,7 @@ describe("Hydration", () => {
|
|
|
112
130
|
test("imports claim and claimText from @sigil-dev/runtime", () => {
|
|
113
131
|
const code = transform(`const el = <div></div>`, { mode: "hydrate" });
|
|
114
132
|
expect(code).toContain(
|
|
115
|
-
"createSignal, createRawSignal, createEffect, createMemo, reconcile, claim, claimText, claimComment, hydrateKeyedList, insert, applyDirective, getHydrationNodes, pushHydrationNodes, popHydrationNodes, snapshot, tracking, untrack, createRoot, createEffectPre, createInspectEffect, withEffectScope, mountTransition, removeAnimated",
|
|
133
|
+
"createSignal, createRawSignal, createEffect, createMemo, reconcile, claim, claimText, claimComment, hydrateKeyedList, hydrateSlot, insert, applyDirective, getHydrationNodes, pushHydrationNodes, popHydrationNodes, snapshot, tracking, untrack, createRoot, createEffectPre, createInspectEffect, withEffectScope, mountTransition, removeAnimated",
|
|
116
134
|
);
|
|
117
135
|
});
|
|
118
136
|
|
|
@@ -144,4 +162,115 @@ describe("Hydration", () => {
|
|
|
144
162
|
// Inner div uses claim
|
|
145
163
|
expect(code).toContain('claim(__nodes, "div")');
|
|
146
164
|
});
|
|
165
|
+
|
|
166
|
+
test("component children are deferred thunks in hydrate mode", () => {
|
|
167
|
+
const code = transform(
|
|
168
|
+
`const Wrap = ({ children }) => <div>{children}</div>;
|
|
169
|
+
const el = <Wrap><span>Hi</span></Wrap>`,
|
|
170
|
+
{ mode: "hydrate" },
|
|
171
|
+
);
|
|
172
|
+
// children must not be evaluated at the call site — they claim from the
|
|
173
|
+
// component's own slot, which the caller cannot see
|
|
174
|
+
expect(code).toMatch(/children:\s*\[\(\)\s*=>\s*\{/);
|
|
175
|
+
// the {children} site consumes the SSR slot and hydrates against it
|
|
176
|
+
expect(code).toContain("hydrateSlot(_el1, _el0, () => children)");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("dom mode still evaluates component children eagerly", () => {
|
|
180
|
+
const code = transform(
|
|
181
|
+
`const Wrap = ({ children }) => <div>{children}</div>;
|
|
182
|
+
const el = <Wrap><span>Hi</span></Wrap>`,
|
|
183
|
+
{ mode: "dom" },
|
|
184
|
+
);
|
|
185
|
+
expect(code).toMatch(/children:\s*\[\(\(\)\s*=>\s*\{/);
|
|
186
|
+
expect(code).not.toContain("hydrateSlot(");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("hydrating a component with children leaves the SSR DOM untouched", () => {
|
|
190
|
+
// Regression: children were claimed from the caller's pool, so a nested
|
|
191
|
+
// component found no match, built a fresh subtree, and the sibling
|
|
192
|
+
// element stole the wrapper's own SSR root — every child rendered twice.
|
|
193
|
+
const src = `
|
|
194
|
+
function Wrap({ children }) { return <div class="wrap">{children}</div>; }
|
|
195
|
+
function Item({ label }) { return <p class="item">{label}</p>; }
|
|
196
|
+
export const App = () => (
|
|
197
|
+
<section class="sec">
|
|
198
|
+
<Wrap>
|
|
199
|
+
<Item label="alpha" />
|
|
200
|
+
<div class="grid">grid</div>
|
|
201
|
+
</Wrap>
|
|
202
|
+
</section>
|
|
203
|
+
);
|
|
204
|
+
`;
|
|
205
|
+
const html = String(evalApp(transform(src, { mode: "ssr" }))());
|
|
206
|
+
const host = document.createElement("div");
|
|
207
|
+
host.innerHTML = html;
|
|
208
|
+
|
|
209
|
+
const app = evalApp(transform(src, { mode: "hydrate" }));
|
|
210
|
+
runtime.pushHydrationNodes(Array.from(host.childNodes));
|
|
211
|
+
app();
|
|
212
|
+
runtime.popHydrationNodes();
|
|
213
|
+
|
|
214
|
+
expect(host.querySelectorAll("div.wrap").length).toBe(1);
|
|
215
|
+
expect(host.querySelectorAll("p.item").length).toBe(1);
|
|
216
|
+
expect(host.querySelectorAll("div.grid").length).toBe(1);
|
|
217
|
+
// perfect hydration: claims only, no re-render
|
|
218
|
+
expect(host.innerHTML).toBe(html);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("component children render once on an empty pool (SPA navigation)", () => {
|
|
222
|
+
const src = `
|
|
223
|
+
function Wrap({ children }) { return <div class="wrap">{children}</div>; }
|
|
224
|
+
function Item({ label }) { return <p class="item">{label}</p>; }
|
|
225
|
+
export const App = () => (
|
|
226
|
+
<section class="sec">
|
|
227
|
+
<Wrap>
|
|
228
|
+
<Item label="alpha" />
|
|
229
|
+
</Wrap>
|
|
230
|
+
</section>
|
|
231
|
+
);
|
|
232
|
+
`;
|
|
233
|
+
const app = evalApp(transform(src, { mode: "hydrate" }));
|
|
234
|
+
const host = document.createElement("div");
|
|
235
|
+
runtime.pushHydrationNodes([]);
|
|
236
|
+
runtime.insert(host, app());
|
|
237
|
+
runtime.popHydrationNodes();
|
|
238
|
+
|
|
239
|
+
expect(host.querySelectorAll("div.wrap").length).toBe(1);
|
|
240
|
+
expect(host.querySelectorAll("p.item").length).toBe(1);
|
|
241
|
+
expect(host.querySelector("p.item")?.textContent).toBe("alpha");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("a static list inside a slot hydrates its SSR items in place", () => {
|
|
245
|
+
// The slot value must stay lazy: evaluated as a call argument the map
|
|
246
|
+
// bodies claimed from the enclosing pool, missed the SSR items and
|
|
247
|
+
// appended fresh copies next to them.
|
|
248
|
+
const src = `
|
|
249
|
+
function Wrap({ children }) { return <div class="wrap">{children}</div>; }
|
|
250
|
+
export const App = () => (
|
|
251
|
+
<section class="sec">
|
|
252
|
+
<Wrap>
|
|
253
|
+
<div class="grid">
|
|
254
|
+
{["a", "b", "c"].map((c) => <span class="cell">{c}</span>)}
|
|
255
|
+
</div>
|
|
256
|
+
</Wrap>
|
|
257
|
+
</section>
|
|
258
|
+
);
|
|
259
|
+
`;
|
|
260
|
+
const html = String(evalApp(transform(src, { mode: "ssr" }))());
|
|
261
|
+
const host = document.createElement("div");
|
|
262
|
+
host.innerHTML = html;
|
|
263
|
+
|
|
264
|
+
const app = evalApp(transform(src, { mode: "hydrate" }));
|
|
265
|
+
runtime.pushHydrationNodes(Array.from(host.childNodes));
|
|
266
|
+
app();
|
|
267
|
+
runtime.popHydrationNodes();
|
|
268
|
+
|
|
269
|
+
expect(host.querySelectorAll("span.cell").length).toBe(3);
|
|
270
|
+
expect(
|
|
271
|
+
Array.from(host.querySelectorAll("span.cell")).map(
|
|
272
|
+
(el) => el.textContent,
|
|
273
|
+
),
|
|
274
|
+
).toEqual(["a", "b", "c"]);
|
|
275
|
+
});
|
|
147
276
|
});
|