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