@sigil-dev/compiler 0.9.1 → 0.9.3
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/index.ts +4 -4
- package/jsr.json +8 -8
- package/package.json +1 -1
- package/src/babel/handlers/dom/derived.ts +62 -62
- package/src/babel/handlers/dom/effect.ts +15 -15
- package/src/babel/handlers/dom/globals.ts +105 -105
- package/src/babel/handlers/dom/state.ts +107 -104
- package/src/babel/handlers/ssr/derived.ts +27 -27
- package/src/babel/handlers/ssr/state.ts +11 -11
- package/src/babel/index.ts +594 -589
- package/src/babel/jsx/anchor-mount.ts +389 -389
- package/src/babel/jsx/children.ts +124 -124
- package/src/babel/jsx/element.ts +831 -831
- package/src/babel/jsx/fragment.ts +282 -282
- package/src/babel/jsx/index.ts +2 -2
- package/src/babel/jsx/keyed-list.ts +278 -278
- package/src/babel/jsx/ssr.ts +319 -319
- package/src/babel/jsx/text-node.ts +192 -192
- package/src/babel/jsx/utils.ts +179 -179
- package/src/babel/util/bind.ts +139 -139
- package/src/babel/util/css.ts +151 -151
- package/src/babel/util/dead-code.ts +28 -28
- package/src/babel/util/helpers.ts +46 -46
- package/src/babel/util/magic.ts +11 -11
- package/src/bun-plugin.ts +92 -90
- package/src/vite/index.ts +58 -58
- package/test/async-components.test.ts +24 -24
- package/test/components.test.ts +129 -129
- package/test/containsSignal.test.ts +288 -288
- package/test/fragments.test.ts +90 -90
- package/test/helpers/transform.ts +45 -48
- package/test/hydration.test.ts +147 -147
- package/test/jsx.test.ts +197 -197
- package/test/keyed-lists.test.ts +88 -88
- package/test/reactivity.test.ts +225 -225
- package/test/scoped-css.test.ts +129 -129
- package/test/ssr.test.ts +130 -130
- package/test/tree-shaking.test.ts +78 -78
- package/tsconfig.json +30 -30
package/test/fragments.test.ts
CHANGED
|
@@ -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,48 +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: [
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
})?.code ?? ""
|
|
47
|
-
);
|
|
48
|
-
}
|
|
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
|
+
}
|
package/test/hydration.test.ts
CHANGED
|
@@ -1,147 +1,147 @@
|
|
|
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, getHydrationNodes, pushHydrationNodes, popHydrationNodes, snapshot, tracking, createEffectPre, createInspectEffect, withEffectScope",
|
|
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 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, getHydrationNodes, pushHydrationNodes, popHydrationNodes, snapshot, tracking, createEffectPre, createInspectEffect, withEffectScope",
|
|
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
|
+
});
|