@sigil-dev/compiler 0.9.4 → 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.
Files changed (40) hide show
  1. package/index.ts +4 -4
  2. package/jsr.json +8 -8
  3. package/package.json +2 -1
  4. package/src/babel/handlers/dom/derived.ts +62 -62
  5. package/src/babel/handlers/dom/effect.ts +15 -15
  6. package/src/babel/handlers/dom/globals.ts +105 -105
  7. package/src/babel/handlers/dom/state.ts +107 -107
  8. package/src/babel/handlers/ssr/derived.ts +27 -27
  9. package/src/babel/handlers/ssr/state.ts +11 -11
  10. package/src/babel/index.ts +9 -0
  11. package/src/babel/jsx/anchor-mount.ts +24 -1
  12. package/src/babel/jsx/children.ts +53 -69
  13. package/src/babel/jsx/element.ts +76 -50
  14. package/src/babel/jsx/fragment.ts +8 -31
  15. package/src/babel/jsx/index.ts +2 -2
  16. package/src/babel/jsx/keyed-list.ts +309 -309
  17. package/src/babel/jsx/ssr.ts +351 -324
  18. package/src/babel/jsx/text-node.ts +192 -192
  19. package/src/babel/jsx/utils.ts +179 -179
  20. package/src/babel/util/bind.ts +139 -139
  21. package/src/babel/util/css.ts +151 -151
  22. package/src/babel/util/dead-code.ts +28 -28
  23. package/src/babel/util/helpers.ts +46 -46
  24. package/src/babel/util/magic.ts +11 -11
  25. package/src/bun-plugin.ts +92 -92
  26. package/src/vite/index.ts +58 -58
  27. package/test/async-components.test.ts +59 -59
  28. package/test/components.test.ts +129 -129
  29. package/test/containsSignal.test.ts +288 -288
  30. package/test/fragments.test.ts +90 -90
  31. package/test/helpers/transform.ts +45 -45
  32. package/test/hydration.test.ts +130 -1
  33. package/test/jsx.test.ts +227 -225
  34. package/test/keyed-lists.test.ts +126 -126
  35. package/test/reactivity.test.ts +225 -225
  36. package/test/scoped-css.test.ts +129 -129
  37. package/test/ssr.test.ts +130 -130
  38. package/test/style-object.test.ts +13 -0
  39. package/test/tree-shaking.test.ts +78 -78
  40. package/tsconfig.json +30 -30
@@ -1,90 +1,90 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { transform } from "./helpers/transform.ts";
3
-
4
- describe("Fragments", () => {
5
- test("empty fragment", () => {
6
- const result = transform(`const el = <></>;`);
7
- expect(result).toContain("document.createDocumentFragment()");
8
- });
9
-
10
- test("fragment with elements", () => {
11
- const result = transform(`const el = <><span>A</span><span>B</span></>;`);
12
- expect(result).toContain("document.createDocumentFragment()");
13
- expect(result).toContain('document.createElement("span")');
14
- expect(result).toContain(".append(");
15
- });
16
-
17
- test("fragment in native parent appends all nodes", () => {
18
- const result = transform(
19
- `const el = <div><><span>A</span><span>B</span></></div>;`,
20
- );
21
- expect(result).toContain('document.createElement("div")');
22
- expect(result).toContain("document.createDocumentFragment()");
23
- expect(result).toContain(".append(");
24
- });
25
-
26
- test("fragment with mixed children", () => {
27
- const result = transform(`const el = <>Hello<span>World</span></>;`);
28
- expect(result).toContain("document.createDocumentFragment()");
29
- expect(result).toContain('"Hello"');
30
- expect(result).toContain('document.createElement("span")');
31
- });
32
-
33
- test("fragment with reactive children", () => {
34
- const result = transform(`
35
- let count = $state(0);
36
- const el = <>{count}</>;
37
- `);
38
- expect(result).toContain("document.createDocumentFragment()");
39
- expect(result).toContain("createEffect(");
40
- expect(result).toContain("count()");
41
- });
42
-
43
- test("fragment returns a single variable", () => {
44
- const result = transform(`const el = <><div /></>;`);
45
- // Should return a single fragment variable, not an array
46
- expect(result).toMatch(/_el\d+ = document\.createDocumentFragment\(\)/);
47
- });
48
-
49
- test("nested fragments", () => {
50
- const result = transform(
51
- `const el = <><><span>A</span></><span>B</span></>;`,
52
- );
53
- expect(result).toContain("document.createDocumentFragment()");
54
- });
55
-
56
- test("fragment with text only", () => {
57
- const result = transform(`const el = <>Hello</>;`);
58
- expect(result).toContain("document.createDocumentFragment()");
59
- expect(result).toContain('"Hello"');
60
- });
61
-
62
- test("DOM order: fragment children interleaved with siblings", () => {
63
- const result = transform(`
64
- const el = (
65
- <div>
66
- <><span>a</span><span>b</span></>
67
- <span>c</span>
68
- </div>
69
- );
70
- `);
71
- // Compiled output:
72
- // div = createElement("div")
73
- // frag = createDocumentFragment()
74
- // spanA = createElement("span") -> append("a") -> frag.append(spanA)
75
- // spanB = createElement("span") -> append("b") -> frag.append(spanB)
76
- // div.append(frag) <- browser inlines fragment children
77
- // spanC = createElement("span") -> append("c") -> div.append(spanC)
78
- // DOM order: a, b, c (no fragment marker)
79
- expect(result).toContain("document.createDocumentFragment()");
80
- // Fragment is appended to div (not span c before fragment)
81
- // div.append(frag) should come before div.append(spanC)
82
- const fragAppendIdx = result.indexOf(".append(_el1)");
83
- const spanCAppendIdx = result.indexOf("_el0.append(_el4)");
84
- expect(fragAppendIdx).toBeLessThan(spanCAppendIdx);
85
- // Span a and b go into fragment, span c goes into div directly
86
- expect(result).toContain('createTextNode("a")');
87
- expect(result).toContain('createTextNode("b")');
88
- expect(result).toContain('createTextNode("c")');
89
- });
90
- });
1
+ import { describe, expect, test } from "bun:test";
2
+ import { transform } from "./helpers/transform.ts";
3
+
4
+ describe("Fragments", () => {
5
+ test("empty fragment", () => {
6
+ const result = transform(`const el = <></>;`);
7
+ expect(result).toContain("document.createDocumentFragment()");
8
+ });
9
+
10
+ test("fragment with elements", () => {
11
+ const result = transform(`const el = <><span>A</span><span>B</span></>;`);
12
+ expect(result).toContain("document.createDocumentFragment()");
13
+ expect(result).toContain('document.createElement("span")');
14
+ expect(result).toContain(".append(");
15
+ });
16
+
17
+ test("fragment in native parent appends all nodes", () => {
18
+ const result = transform(
19
+ `const el = <div><><span>A</span><span>B</span></></div>;`,
20
+ );
21
+ expect(result).toContain('document.createElement("div")');
22
+ expect(result).toContain("document.createDocumentFragment()");
23
+ expect(result).toContain(".append(");
24
+ });
25
+
26
+ test("fragment with mixed children", () => {
27
+ const result = transform(`const el = <>Hello<span>World</span></>;`);
28
+ expect(result).toContain("document.createDocumentFragment()");
29
+ expect(result).toContain('"Hello"');
30
+ expect(result).toContain('document.createElement("span")');
31
+ });
32
+
33
+ test("fragment with reactive children", () => {
34
+ const result = transform(`
35
+ let count = $state(0);
36
+ const el = <>{count}</>;
37
+ `);
38
+ expect(result).toContain("document.createDocumentFragment()");
39
+ expect(result).toContain("createEffect(");
40
+ expect(result).toContain("count()");
41
+ });
42
+
43
+ test("fragment returns a single variable", () => {
44
+ const result = transform(`const el = <><div /></>;`);
45
+ // Should return a single fragment variable, not an array
46
+ expect(result).toMatch(/_el\d+ = document\.createDocumentFragment\(\)/);
47
+ });
48
+
49
+ test("nested fragments", () => {
50
+ const result = transform(
51
+ `const el = <><><span>A</span></><span>B</span></>;`,
52
+ );
53
+ expect(result).toContain("document.createDocumentFragment()");
54
+ });
55
+
56
+ test("fragment with text only", () => {
57
+ const result = transform(`const el = <>Hello</>;`);
58
+ expect(result).toContain("document.createDocumentFragment()");
59
+ expect(result).toContain('"Hello"');
60
+ });
61
+
62
+ test("DOM order: fragment children interleaved with siblings", () => {
63
+ const result = transform(`
64
+ const el = (
65
+ <div>
66
+ <><span>a</span><span>b</span></>
67
+ <span>c</span>
68
+ </div>
69
+ );
70
+ `);
71
+ // Compiled output:
72
+ // div = createElement("div")
73
+ // frag = createDocumentFragment()
74
+ // spanA = createElement("span") -> append("a") -> frag.append(spanA)
75
+ // spanB = createElement("span") -> append("b") -> frag.append(spanB)
76
+ // div.append(frag) <- browser inlines fragment children
77
+ // spanC = createElement("span") -> append("c") -> div.append(spanC)
78
+ // DOM order: a, b, c (no fragment marker)
79
+ expect(result).toContain("document.createDocumentFragment()");
80
+ // Fragment is appended to div (not span c before fragment)
81
+ // div.append(frag) should come before div.append(spanC)
82
+ const fragAppendIdx = result.indexOf(".append(_el1)");
83
+ const spanCAppendIdx = result.indexOf("_el0.append(_el4)");
84
+ expect(fragAppendIdx).toBeLessThan(spanCAppendIdx);
85
+ // Span a and b go into fragment, span c goes into div directly
86
+ expect(result).toContain('createTextNode("a")');
87
+ expect(result).toContain('createTextNode("b")');
88
+ expect(result).toContain('createTextNode("c")');
89
+ });
90
+ });
@@ -1,45 +1,45 @@
1
- import { transformSync } from "@babel/core";
2
- import sigilPlugin from "../../src/babel/index.ts";
3
-
4
- export function transform(code: string, hash?: string) {
5
- const plugins: any[] = ["@babel/plugin-syntax-jsx"];
6
- if (hash) {
7
- plugins.push([sigilPlugin, { hash }]);
8
- } else {
9
- plugins.push(sigilPlugin);
10
- }
11
- return (
12
- transformSync(code, {
13
- plugins,
14
- filename: "test.tsx",
15
- })?.code ?? ""
16
- );
17
- }
18
-
19
- export function transformSSR(code: string) {
20
- return (
21
- transformSync(code, {
22
- plugins: ["@babel/plugin-syntax-jsx", [sigilPlugin, { mode: "ssr" }]],
23
- filename: "test.tsx",
24
- })?.code ?? ""
25
- );
26
- }
27
- export function transformDOM(code: string, hash?: string) {
28
- return (
29
- transformSync(code, {
30
- plugins: [
31
- "@babel/plugin-syntax-jsx",
32
- [sigilPlugin, { mode: "dom", hash }],
33
- ],
34
- filename: "test.tsx",
35
- })?.code ?? ""
36
- );
37
- }
38
- export function transformAsync(code: string) {
39
- return (
40
- transformSync(code, {
41
- plugins: ["@babel/plugin-syntax-jsx", [sigilPlugin, { mode: "dom" }]],
42
- filename: "test.tsx",
43
- })?.code ?? ""
44
- );
45
- }
1
+ import { transformSync } from "@babel/core";
2
+ import sigilPlugin from "../../src/babel/index.ts";
3
+
4
+ export function transform(code: string, hash?: string) {
5
+ const plugins: any[] = ["@babel/plugin-syntax-jsx"];
6
+ if (hash) {
7
+ plugins.push([sigilPlugin, { hash }]);
8
+ } else {
9
+ plugins.push(sigilPlugin);
10
+ }
11
+ return (
12
+ transformSync(code, {
13
+ plugins,
14
+ filename: "test.tsx",
15
+ })?.code ?? ""
16
+ );
17
+ }
18
+
19
+ export function transformSSR(code: string) {
20
+ return (
21
+ transformSync(code, {
22
+ plugins: ["@babel/plugin-syntax-jsx", [sigilPlugin, { mode: "ssr" }]],
23
+ filename: "test.tsx",
24
+ })?.code ?? ""
25
+ );
26
+ }
27
+ export function transformDOM(code: string, hash?: string) {
28
+ return (
29
+ transformSync(code, {
30
+ plugins: [
31
+ "@babel/plugin-syntax-jsx",
32
+ [sigilPlugin, { mode: "dom", hash }],
33
+ ],
34
+ filename: "test.tsx",
35
+ })?.code ?? ""
36
+ );
37
+ }
38
+ export function transformAsync(code: string) {
39
+ return (
40
+ transformSync(code, {
41
+ plugins: ["@babel/plugin-syntax-jsx", [sigilPlugin, { mode: "dom" }]],
42
+ filename: "test.tsx",
43
+ })?.code ?? ""
44
+ );
45
+ }
@@ -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" });
@@ -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
  });