@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.
@@ -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;
@@ -20,19 +20,43 @@ import {
20
20
  } from "./utils";
21
21
 
22
22
  /**
23
- * Build a property-access expression for a JSX attribute name.
24
- * Dashed names (data-*, aria-*) require computed access (e.g. el["data-id"])
25
- * because t.identifier() rejects names with dashes.
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 attrAccess(varName: string, attrName: string): t.MemberExpression {
28
- if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(attrName)) {
29
- return t.memberExpression(t.identifier(varName), t.identifier(attrName));
30
- }
31
- return t.memberExpression(
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
- true,
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
- t.assignmentExpression(
485
- "=",
486
- attrAccess(varName, realAttr),
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(() => varName.realAttr = expr)
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
- t.assignmentExpression(
581
- "=",
582
- attrAccess(varName, realAttr),
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
- // varName.realAttr = expr
628
+ // setAttribute for dashed, runtime setProp (attribute-safe on SVG) otherwise
594
629
  statements.push(
595
630
  t.expressionStatement(
596
- t.assignmentExpression("=", attrAccess(varName, realAttr), expr),
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
- const claimCommentArgs: t.Expression[] = [
797
- t.identifier(childNodesVar!),
798
- t.stringLiteral("g"),
799
- ];
800
- if (varName) claimCommentArgs.push(t.identifier(varName));
801
- const poolLen = t.memberExpression(
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.ifStatement(
828
- t.binaryExpression(">", poolLen, t.numericLiteral(0)),
829
- t.blockStatement([claimG, claimSlashG]),
830
- t.blockStatement([insertCall]),
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
- 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(
@@ -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 (
@@ -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 = "foo"');
65
- expect(code).toContain('id = "bar"');
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
  });