@sigil-dev/compiler 0.9.5 → 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.
- package/package.json +1 -1
- package/src/babel/index.ts +1 -0
- package/src/babel/jsx/anchor-mount.ts +408 -385
- package/src/babel/jsx/children.ts +108 -124
- package/src/babel/jsx/element.ts +876 -848
- package/src/babel/jsx/fragment.ts +259 -282
- package/test/hydration.test.ts +276 -147
package/test/hydration.test.ts
CHANGED
|
@@ -1,147 +1,276 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { transformSync } from "@babel/core";
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
test("
|
|
39
|
-
const code = transform(`const el = <div
|
|
40
|
-
|
|
41
|
-
expect(code).not.toContain("
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
);
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
);
|
|
73
|
-
expect(code).toContain("
|
|
74
|
-
expect(code).toContain("
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("
|
|
78
|
-
const code = transform(`const el = <div
|
|
79
|
-
mode: "hydrate",
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
expect(code).
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test("
|
|
87
|
-
const code = transform(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
expect(code).toContain(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
expect(code).toContain(
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
);
|
|
132
|
-
expect(code).toContain(
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { transformSync } from "@babel/core";
|
|
3
|
+
import * as runtime from "../../runtime/index.ts";
|
|
4
|
+
import sigilPlugin from "../src/babel/index.ts";
|
|
5
|
+
|
|
6
|
+
function transform(
|
|
7
|
+
source: string,
|
|
8
|
+
opts?: { mode?: "dom" | "ssr" | "hydrate" },
|
|
9
|
+
) {
|
|
10
|
+
const result = transformSync(source, {
|
|
11
|
+
plugins: [
|
|
12
|
+
"@babel/plugin-syntax-jsx",
|
|
13
|
+
[sigilPlugin, { mode: opts?.mode ?? "dom" }],
|
|
14
|
+
],
|
|
15
|
+
filename: "test.tsx",
|
|
16
|
+
});
|
|
17
|
+
return result!.code!;
|
|
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
|
+
|
|
37
|
+
describe("Hydration", () => {
|
|
38
|
+
test("element uses claim instead of createElement", () => {
|
|
39
|
+
const code = transform(`const el = <div></div>`, { mode: "hydrate" });
|
|
40
|
+
expect(code).toContain("claim(");
|
|
41
|
+
expect(code).not.toContain("createElement");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("nested elements use claim with scoped pools", () => {
|
|
45
|
+
const code = transform(`const el = <div><span></span></div>`, {
|
|
46
|
+
mode: "hydrate",
|
|
47
|
+
});
|
|
48
|
+
// Should claim div from root __nodes pool (no parent for root)
|
|
49
|
+
expect(code).toContain('claim(__nodes, "div")');
|
|
50
|
+
// Should scope to div's children (genId produces _el1 for pool)
|
|
51
|
+
expect(code).toContain("Array.from(_el0.childNodes)");
|
|
52
|
+
// Should claim span from div's child pool with parent for SPA fallback
|
|
53
|
+
expect(code).toContain('claim(_el1, "span", _el0)');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("static text creates text node only when pool is empty (SPA nav)", () => {
|
|
57
|
+
const code = transform(`const el = <div>Hello</div>`, { mode: "hydrate" });
|
|
58
|
+
// Static text: SSR hydration leaves it in DOM; SPA nav creates it when pool is empty
|
|
59
|
+
expect(code).not.toContain("claimText(");
|
|
60
|
+
// Should emit a conditional createTextNode guarded by pool.length === 0
|
|
61
|
+
expect(code).toContain("createTextNode");
|
|
62
|
+
expect(code).toContain(".length === 0");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("reactive text node uses claimComment anchor + Text check for hydration", () => {
|
|
66
|
+
const code = transform(
|
|
67
|
+
`let name = $state('Alice'); const el = <span>{name}</span>`,
|
|
68
|
+
{ mode: "hydrate" },
|
|
69
|
+
);
|
|
70
|
+
// Dynamic text: claims <!--g--> as anchor (fixes adjacent-text bug vs DOM walk)
|
|
71
|
+
// then gets nextSibling; creates text node if it's not a Text (SPA navigation)
|
|
72
|
+
expect(code).toContain("claimComment(");
|
|
73
|
+
expect(code).toContain("createEffect");
|
|
74
|
+
expect(code).toContain("instanceof Text");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("attributes still work in hydrate mode", () => {
|
|
78
|
+
const code = transform(`const el = <div class="foo" id="bar"></div>`, {
|
|
79
|
+
mode: "hydrate",
|
|
80
|
+
});
|
|
81
|
+
expect(code).toContain("claim(");
|
|
82
|
+
expect(code).toContain('className = "foo"');
|
|
83
|
+
expect(code).toContain('id = "bar"');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("event handlers still attached in hydrate mode", () => {
|
|
87
|
+
const code = transform(
|
|
88
|
+
`const el = <button onClick={() => {}}>Click</button>`,
|
|
89
|
+
{ mode: "hydrate" },
|
|
90
|
+
);
|
|
91
|
+
expect(code).toContain("claim(");
|
|
92
|
+
expect(code).toContain("addEventListener");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("no unconditional append calls in hydrate mode", () => {
|
|
96
|
+
const code = transform(`const el = <div><span></span></div>`, {
|
|
97
|
+
mode: "hydrate",
|
|
98
|
+
});
|
|
99
|
+
// Elements are claimed from pool — no unconditional appends for elements
|
|
100
|
+
// (static text does emit a conditional .append guarded by pool.length === 0)
|
|
101
|
+
expect(code).not.toContain('claim(__nodes, "div").append(');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("dom mode still uses createElement (no regression)", () => {
|
|
105
|
+
const code = transform(`const el = <div></div>`, { mode: "dom" });
|
|
106
|
+
expect(code).toContain("createElement");
|
|
107
|
+
expect(code).not.toContain("claim(");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("dom mode still appends children (no regression)", () => {
|
|
111
|
+
const code = transform(`const el = <div><span></span></div>`, {
|
|
112
|
+
mode: "dom",
|
|
113
|
+
});
|
|
114
|
+
expect(code).toContain(".append(");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("three levels deep: div > p > span", () => {
|
|
118
|
+
const code = transform(`const el = <div><p><span></span></p></div>`, {
|
|
119
|
+
mode: "hydrate",
|
|
120
|
+
});
|
|
121
|
+
// Three scopes (Array.from)
|
|
122
|
+
const scopes = code.match(/Array\.from\(.*\.childNodes\)/g);
|
|
123
|
+
expect(scopes?.length).toBe(3);
|
|
124
|
+
// Root claims from __nodes (no parent), children use generated pool names with parent
|
|
125
|
+
expect(code).toContain('claim(__nodes, "div")');
|
|
126
|
+
expect(code).toContain('claim(_el1, "p", _el0)');
|
|
127
|
+
expect(code).toContain('claim(_el3, "span", _el2)');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("imports claim and claimText from @sigil-dev/runtime", () => {
|
|
131
|
+
const code = transform(`const el = <div></div>`, { mode: "hydrate" });
|
|
132
|
+
expect(code).toContain(
|
|
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",
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("dom mode does not import claim/claimText", () => {
|
|
138
|
+
const code = transform(`const el = <div></div>`, { mode: "dom" });
|
|
139
|
+
// claim/claimText are imported but unused — tree-shaking removes them
|
|
140
|
+
// Actually, they're always imported. That's fine — bundlers tree-shake.
|
|
141
|
+
// Just verify createElement is used
|
|
142
|
+
expect(code).toContain("createElement");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("reactive attribute still wraps in createEffect", () => {
|
|
146
|
+
const code = transform(
|
|
147
|
+
`let cls = $state('a'); const el = <div class={cls}></div>`,
|
|
148
|
+
{ mode: "hydrate" },
|
|
149
|
+
);
|
|
150
|
+
expect(code).toContain("claim(");
|
|
151
|
+
expect(code).toContain("createEffect");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("components in hydrate mode pass children as props", () => {
|
|
155
|
+
const code = transform(
|
|
156
|
+
`const App = (props) => <div>{props.children}</div>;
|
|
157
|
+
const el = <App><span>Hi</span></App>`,
|
|
158
|
+
{ mode: "hydrate" },
|
|
159
|
+
);
|
|
160
|
+
// Component still calls function
|
|
161
|
+
expect(code).toContain("App(");
|
|
162
|
+
// Inner div uses claim
|
|
163
|
+
expect(code).toContain('claim(__nodes, "div")');
|
|
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
|
+
});
|
|
276
|
+
});
|