@sigil-dev/compiler 0.9.7 → 0.9.11

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.
@@ -0,0 +1,639 @@
1
+ /**
2
+ * The same JSX rendered three ways must agree: server HTML (ssr), that HTML
3
+ * after hydration, and a fresh client render (hydrate code with an empty pool,
4
+ * which is what client-side navigation runs).
5
+ */
6
+ import { describe, expect, test } from "bun:test";
7
+ import { transformSync } from "@babel/core";
8
+ import * as runtime from "../../runtime/index.ts";
9
+ import sigilPlugin from "../src/babel/index.ts";
10
+
11
+ type Mode = "ssr" | "hydrate";
12
+
13
+ function compile(source: string, mode: Mode, hash?: string): () => unknown {
14
+ const code = transformSync(source, {
15
+ plugins: ["@babel/plugin-syntax-jsx", [sigilPlugin, { mode, hash }]],
16
+ filename: "t.tsx",
17
+ })!.code!;
18
+ const body = code
19
+ .replace(/import\s*\{[^}]*\}\s*from\s*["']@sigil-dev\/runtime["'];?/g, "")
20
+ .replace(/^export\s+/gm, "");
21
+ return new Function(
22
+ "document",
23
+ ...Object.keys(runtime),
24
+ `${body};\nreturn App;`,
25
+ )(globalThis.document, ...Object.values(runtime));
26
+ }
27
+
28
+ const noMarkers = (html: string) => html.replace(/<!--[^>]*-->/g, "");
29
+
30
+ function render(
31
+ source: string,
32
+ opts: { hash?: string; beforeHydrate?: (host: HTMLElement) => void } = {},
33
+ ) {
34
+ const ssr = String(compile(source, "ssr", opts.hash)());
35
+
36
+ const host = document.createElement("div");
37
+ host.innerHTML = ssr;
38
+ opts.beforeHydrate?.(host);
39
+ runtime.pushHydrationNodes(Array.from(host.childNodes));
40
+ compile(source, "hydrate", opts.hash)();
41
+ runtime.popHydrationNodes();
42
+
43
+ const spa = document.createElement("div");
44
+ runtime.pushHydrationNodes([]);
45
+ runtime.insert(spa, compile(source, "hydrate", opts.hash)());
46
+ runtime.popHydrationNodes();
47
+
48
+ return { ssr: noMarkers(ssr), hydrated: host, spa };
49
+ }
50
+
51
+ /** Server HTML, hydrated DOM and client render all equal `html`. */
52
+ function expectAll(source: string, html: string) {
53
+ const r = render(source);
54
+ expect(r.ssr).toBe(html);
55
+ expect(noMarkers(r.hydrated.innerHTML)).toBe(html);
56
+ expect(noMarkers(r.spa.innerHTML)).toBe(html);
57
+ return r;
58
+ }
59
+
60
+ describe("whitespace", () => {
61
+ test("spaces next to elements survive inside fragments", () => {
62
+ expectAll(
63
+ `export const App = () => <div><>Hello <b>big</b> world</></div>;`,
64
+ "<div>Hello <b>big</b> world</div>",
65
+ );
66
+ });
67
+
68
+ test("a lone space between elements is kept", () => {
69
+ expectAll(
70
+ `export const App = () => <p><b>a</b> <i>b</i></p>;`,
71
+ "<p><b>a</b> <i>b</i></p>",
72
+ );
73
+ });
74
+
75
+ test("whitespace touching a line break is dropped (standard JSX)", () => {
76
+ expectAll(
77
+ `export const App = () => (
78
+ <p>
79
+ Hello
80
+ <b>big</b> world
81
+ </p>
82
+ );`,
83
+ "<p>Hello<b>big</b> world</p>",
84
+ );
85
+ });
86
+
87
+ test("multi-line text joins with single spaces", () => {
88
+ expectAll(
89
+ `export const App = () => (
90
+ <p>
91
+ one
92
+ two
93
+ </p>
94
+ );`,
95
+ "<p>one two</p>",
96
+ );
97
+ });
98
+
99
+ test("component children keep their spaces", () => {
100
+ expectAll(
101
+ `const Box = ({ children }) => <span>{children}</span>;
102
+ export const App = () => <div><Box>Hi <b>you</b> there</Box></div>;`,
103
+ "<div><span>Hi <b>you</b> there</span></div>",
104
+ );
105
+ });
106
+ });
107
+
108
+ describe("attribute values", () => {
109
+ test("null and undefined leave the attribute out", () => {
110
+ expectAll(
111
+ `const u = undefined, n = null; export const App = () => <a href={u} title={n} data-x={u}>x</a>;`,
112
+ "<a>x</a>",
113
+ );
114
+ });
115
+
116
+ test("dashed attributes spell booleans out", () => {
117
+ expectAll(
118
+ `const t = true, f = false; export const App = () => <div aria-hidden={t} data-open={f} />;`,
119
+ '<div aria-hidden="true" data-open="false"></div>',
120
+ );
121
+ });
122
+
123
+ test("bare attributes render on the server", () => {
124
+ const r = render(
125
+ `export const App = () => <div><script src="/x.js" defer></script><details open></details></div>;`,
126
+ );
127
+ expect(r.ssr).toBe(
128
+ '<div><script src="/x.js" defer></script><details open></details></div>',
129
+ );
130
+ expect(r.spa.querySelector("script")!.defer).toBe(true);
131
+ expect(r.spa.querySelector("details")!.open).toBe(true);
132
+ });
133
+
134
+ test("string attributes are escaped", () => {
135
+ expectAll(
136
+ `export const App = () => <p title='say "hi" &amp; go'>x</p>;`,
137
+ '<p title="say &quot;hi&quot; &amp; go">x</p>',
138
+ );
139
+ });
140
+
141
+ test("spread attributes render on the server and the client", () => {
142
+ const r = expectAll(
143
+ `const props = { id: "s", class: "c", "aria-label": "l", hidden: false, title: undefined };
144
+ export const App = () => <div {...props} />;`,
145
+ '<div id="s" class="c" aria-label="l"></div>',
146
+ );
147
+ expect(r.spa.firstElementChild!.getAttribute("class")).toBe("c");
148
+ });
149
+ });
150
+
151
+ describe("text escaping", () => {
152
+ test("entities in static text stay text", () => {
153
+ expectAll(
154
+ `export const App = () => <p>&lt;ok&gt; &amp; ©</p>;`,
155
+ "<p>&lt;ok&gt; &amp; ©</p>",
156
+ );
157
+ });
158
+
159
+ test("backticks, backslashes and ${ in static text", () => {
160
+ const r = render("export const App = () => <p>a `b` \\n ${'{'}c{'}'}</p>;");
161
+ expect(r.ssr).toBe("<p>a `b` \\n ${c}</p>");
162
+ expect(r.spa.textContent).toBe("a `b` \\n ${c}");
163
+ });
164
+ });
165
+
166
+ describe("styles", () => {
167
+ test("numbers get px on the server and the client", () => {
168
+ const r = render(
169
+ `export const App = () => <p style={{ marginTop: 4, opacity: 0.5, "--gap": 2 }}>x</p>;`,
170
+ );
171
+ expect(r.ssr).toBe('<p style="margin-top:4px;opacity:0.5;--gap:2">x</p>');
172
+ const style = (r.spa.firstElementChild as HTMLElement).style;
173
+ expect(style.marginTop).toBe("4px");
174
+ expect(style.opacity).toBe("0.5");
175
+ expect(style.getPropertyValue("--gap")).toBe("2");
176
+ });
177
+
178
+ test("a style object passed as a value", () => {
179
+ const r = render(
180
+ `const s = { marginTop: 4, color: "red" }; export const App = () => <p style={s}>x</p>;`,
181
+ );
182
+ expect(r.ssr).toBe('<p style="margin-top:4px;color:red">x</p>');
183
+ const style = (r.spa.firstElementChild as HTMLElement).style;
184
+ expect(style.marginTop).toBe("4px");
185
+ expect(style.color).toBe("red");
186
+ });
187
+ });
188
+
189
+ describe("form values", () => {
190
+ test("<textarea value> is its content on the server", () => {
191
+ const r = render(
192
+ `const v = "a < b"; export const App = () => <textarea value={v} />;`,
193
+ );
194
+ expect(r.ssr).toBe("<textarea>a &lt; b</textarea>");
195
+ expect((r.spa.firstElementChild as HTMLTextAreaElement).value).toBe(
196
+ "a < b",
197
+ );
198
+ });
199
+
200
+ test("<select value> selects its option everywhere", () => {
201
+ const src = `const v = "2"; export const App = () => (
202
+ <select value={v}>
203
+ <option value="1">1</option>
204
+ <option value="2">2</option>
205
+ </select>
206
+ );`;
207
+ const r = render(src);
208
+ expect(r.ssr).toBe(
209
+ '<select><option value="1">1</option><option value="2" selected>2</option></select>',
210
+ );
211
+ expect((r.hydrated.firstElementChild as HTMLSelectElement).value).toBe("2");
212
+ expect((r.spa.firstElementChild as HTMLSelectElement).value).toBe("2");
213
+ });
214
+ });
215
+
216
+ describe("hydration keeps the server DOM", () => {
217
+ test("classes and attributes added before hydration stay", () => {
218
+ const r = render(`export const App = () => <div class="a" data-k="1" />;`, {
219
+ beforeHydrate: (host) => {
220
+ host.firstElementChild!.classList.add("added");
221
+ host.firstElementChild!.setAttribute("data-ready", "1");
222
+ },
223
+ });
224
+ const el = r.hydrated.firstElementChild!;
225
+ expect(el.className).toBe("a added");
226
+ expect(el.getAttribute("data-ready")).toBe("1");
227
+ // a fresh render still sets everything
228
+ expect(r.spa.innerHTML).toBe('<div class="a" data-k="1"></div>');
229
+ });
230
+
231
+ test("event listeners still attach to claimed elements", () => {
232
+ const src = `export const App = () => <button onClick={() => (globalThis.__hit = true)}>b</button>;`;
233
+ const r = render(src);
234
+ (r.hydrated.firstElementChild as HTMLElement).click();
235
+ expect((globalThis as Record<string, unknown>).__hit).toBe(true);
236
+ });
237
+
238
+ test("inline <script> text is created on client navigation", () => {
239
+ const r = render(
240
+ `const j = '{"a":1}'; export const App = () => <div><script type="application/json">{j}</script></div>;`,
241
+ );
242
+ expect(r.ssr).toBe(
243
+ '<div><script type="application/json">{"a":1}</script></div>',
244
+ );
245
+ expect(r.hydrated.innerHTML).toBe(r.ssr);
246
+ expect(r.spa.innerHTML).toBe(r.ssr);
247
+ });
248
+ });
249
+
250
+ describe("JSX passed as props", () => {
251
+ test("a JSX prop hydrates inside the component, not over its root", () => {
252
+ const src = `const Dialog = ({ title, actions, children }) => (
253
+ <div class="dlg"><b>{title}</b><div class="body">{children}</div>{actions && <div class="acts">{actions}</div>}</div>
254
+ );
255
+ export const App = () => (
256
+ <section>
257
+ <Dialog title="t" actions={<><div class="a">x</div><div class="a">y</div></>}>
258
+ body
259
+ </Dialog>
260
+ </section>
261
+ );`;
262
+ const html =
263
+ '<section><div class="dlg"><b>t</b><div class="body">body</div><div class="acts"><div class="a">x</div><div class="a">y</div></div></div></section>';
264
+ const r = expectAll(src, html);
265
+ expect(r.hydrated.querySelectorAll(".dlg").length).toBe(1);
266
+ });
267
+
268
+ test("a conditional JSX prop", () => {
269
+ expectAll(
270
+ `const on = true;
271
+ const Card = ({ icon }) => <div class="card">{icon}<p>t</p></div>;
272
+ export const App = () => <div><Card icon={on ? <i>i</i> : <b>b</b>} /></div>;`,
273
+ '<div><div class="card"><i>i</i><p>t</p></div></div>',
274
+ );
275
+ });
276
+ });
277
+
278
+ describe("reactive branches", () => {
279
+ test("a component with text children inside a reactive conditional", () => {
280
+ expectAll(
281
+ `let on = $state(true);
282
+ const Toast = ({ children }) => <div class="t"><span>{children}</span></div>;
283
+ export const App = () => (
284
+ <section>{on ? <Toast>Saved</Toast> : <p>none</p>}</section>
285
+ );`,
286
+ '<section><div class="t"><span>Saved</span></div></section>',
287
+ );
288
+ });
289
+ });
290
+
291
+ describe("reactive component children", () => {
292
+ const src = `let on = $state(true);
293
+ globalThis.__flip = () => { on = !on; };
294
+ const Box = ({ children }) => <div class="box"><h3>t</h3>{children}</div>;
295
+ export const App = () => (
296
+ <section>
297
+ <Box>{on ? <b>yes</b> : <i>no</i>}</Box>
298
+ <p>{on ? "on" : "off"}</p>
299
+ </section>
300
+ );`;
301
+ const flip = () => (globalThis as unknown as { __flip: () => void }).__flip();
302
+
303
+ test("update after hydration", async () => {
304
+ const r = render(src);
305
+ expect(noMarkers(r.hydrated.innerHTML)).toBe(
306
+ '<section><div class="box"><h3>t</h3><b>yes</b></div><p>on</p></section>',
307
+ );
308
+ // render() compiled the module three times; the last one owns __flip,
309
+ // and it drives the spa copy
310
+ flip();
311
+ await runtime.tick();
312
+ expect(noMarkers(r.spa.innerHTML)).toBe(
313
+ '<section><div class="box"><h3>t</h3><i>no</i></div><p>off</p></section>',
314
+ );
315
+ });
316
+
317
+ test("update after hydration (hydrated copy)", async () => {
318
+ const ssr = String(compile(src, "ssr")());
319
+ const host = document.createElement("div");
320
+ host.innerHTML = ssr;
321
+ runtime.pushHydrationNodes(Array.from(host.childNodes));
322
+ compile(src, "hydrate")();
323
+ runtime.popHydrationNodes();
324
+ flip();
325
+ await runtime.tick();
326
+ expect(noMarkers(host.innerHTML)).toBe(
327
+ '<section><div class="box"><h3>t</h3><i>no</i></div><p>off</p></section>',
328
+ );
329
+ });
330
+ });
331
+
332
+ describe("scoped CSS", () => {
333
+ test("the server renders the scope class the client uses", () => {
334
+ const r = render(
335
+ `const c = "x"; export const App = () => <div class="a"><p class={c}>t</p><i /></div>;`,
336
+ { hash: "s1" },
337
+ );
338
+ expect(r.ssr).toBe(
339
+ '<div class="a s1"><p class="x s1">t</p><i class="s1"></i></div>',
340
+ );
341
+ expect(r.spa.innerHTML).toBe(r.ssr);
342
+ });
343
+ });
344
+
345
+ describe("$state.raw", () => {
346
+ test("renders on the server and updates when replaced", async () => {
347
+ const src = `let items = $state.raw([1, 2]);
348
+ globalThis.__replace = () => { items = [...items, 3]; };
349
+ globalThis.__mutate = () => { items.push(99); };
350
+ export const App = () => <p>{items.length}: {items.join(",")}</p>;`;
351
+ const r = expectAll(src, "<p>2: 1,2</p>");
352
+ const g = globalThis as unknown as Record<string, () => void>;
353
+ // no proxy: mutating in place is invisible to the DOM
354
+ g.__mutate();
355
+ await runtime.tick();
356
+ expect(noMarkers(r.spa.innerHTML)).toBe("<p>2: 1,2</p>");
357
+ // replacing the value is what updates it
358
+ g.__replace();
359
+ await runtime.tick();
360
+ expect(noMarkers(r.spa.innerHTML)).toBe("<p>4: 1,2,99,3</p>");
361
+ });
362
+ });
363
+
364
+ describe("keyed lists after hydration", () => {
365
+ // server-rendered rows carry data-key; rows the client creates don't need it
366
+ const plain = (html: string) =>
367
+ noMarkers(html).replace(/ data-key="[^"]*"/g, "");
368
+ const g = globalThis as unknown as Record<string, () => void>;
369
+
370
+ function hydrated(src: string) {
371
+ const ssr = String(compile(src, "ssr")());
372
+ const host = document.createElement("div");
373
+ host.innerHTML = ssr;
374
+ runtime.pushHydrationNodes(Array.from(host.childNodes));
375
+ compile(src, "hydrate")();
376
+ runtime.popHydrationNodes();
377
+ return host;
378
+ }
379
+
380
+ test("listeners and effects inside server-rendered rows work", async () => {
381
+ const host =
382
+ hydrated(`let items = $state([{ id: 1, done: false }, { id: 2, done: false }]);
383
+ export const App = () => (
384
+ <ul>{items.map((it) => (
385
+ <li key={it.id}><button onClick={() => (it.done = !it.done)}>{it.done ? "done" : "todo"}</button></li>
386
+ ))}</ul>
387
+ );`);
388
+ host.querySelectorAll("button")[1]!.click();
389
+ await runtime.tick();
390
+ expect(plain(host.innerHTML)).toBe(
391
+ "<ul><li><button>todo</button></li><li><button>done</button></li></ul>",
392
+ );
393
+ });
394
+
395
+ test("push appends a row and keeps the existing nodes", async () => {
396
+ const host =
397
+ hydrated(`let items = $state([{ id: 1, t: "a" }, { id: 2, t: "b" }]);
398
+ globalThis.__push = () => items.push({ id: 3, t: "c" });
399
+ export const App = () => <main><ul>{items.map((it) => <li key={it.id}>{it.t}</li>)}</ul><p>{items.length}</p></main>;`);
400
+ const before = Array.from(host.querySelectorAll("li"));
401
+ g.__push();
402
+ await runtime.tick();
403
+ expect(plain(host.innerHTML)).toBe(
404
+ "<main><ul><li>a</li><li>b</li><li>c</li></ul><p>3</p></main>",
405
+ );
406
+ const after = Array.from(host.querySelectorAll("li"));
407
+ expect(after[0]).toBe(before[0]);
408
+ expect(after[1]).toBe(before[1]);
409
+ });
410
+
411
+ const rows = `let items = $state([{ id: 1, done: false }, { id: 2, done: false }]);
412
+ globalThis.__push = () => items.push({ id: 3, done: false });
413
+ export const App = () => (
414
+ <ul>{items.map((it) => (
415
+ <li key={it.id}>
416
+ <input type="checkbox" bind:checked={it.done} />
417
+ <button onClick={() => (it.done = !it.done)}>{it.done ? "done" : "todo"}</button>
418
+ </li>
419
+ ))}</ul>
420
+ );`;
421
+ const labels = (host: Element) =>
422
+ Array.from(host.querySelectorAll("button"), (b) => b.textContent).join(",");
423
+
424
+ for (const mode of ["hydrated", "fresh"] as const) {
425
+ test(`rows stay reactive, before and after a push (${mode})`, async () => {
426
+ let host: Element;
427
+ if (mode === "hydrated") host = hydrated(rows);
428
+ else {
429
+ host = document.createElement("div");
430
+ runtime.pushHydrationNodes([]);
431
+ runtime.insert(host, compile(rows, "hydrate")());
432
+ runtime.popHydrationNodes();
433
+ }
434
+ host.querySelectorAll("button")[1]!.click();
435
+ await runtime.tick();
436
+ expect(labels(host)).toBe("todo,done");
437
+ expect(host.querySelectorAll("input")[1]!.checked).toBe(true);
438
+
439
+ g.__push();
440
+ await runtime.tick();
441
+ host.querySelectorAll("button")[0]!.click();
442
+ await runtime.tick();
443
+ expect(labels(host)).toBe("done,done,todo");
444
+
445
+ // bind:checked writes back into the item
446
+ const box = host.querySelectorAll("input")[2]!;
447
+ box.checked = true;
448
+ box.dispatchEvent(new Event("change"));
449
+ await runtime.tick();
450
+ expect(labels(host)).toBe("done,done,done");
451
+ });
452
+ }
453
+
454
+ test("an element after the list is never taken for a new row", async () => {
455
+ const host = hydrated(`let items = $state([{ id: 1 }]);
456
+ globalThis.__push = () => items.push({ id: 2 });
457
+ export const App = () => <ul>{items.map((it) => <li key={it.id}>{it.id}</li>)}<li class="last">end</li></ul>;`);
458
+ g.__push();
459
+ await runtime.tick();
460
+ expect(plain(host.innerHTML)).toBe(
461
+ '<ul><li>1</li><li>2</li><li class="last">end</li></ul>',
462
+ );
463
+ });
464
+ });
465
+
466
+ describe("reactive props", () => {
467
+ const g = globalThis as unknown as Record<string, () => void>;
468
+ const both = async (
469
+ src: string,
470
+ html: string,
471
+ act: () => void,
472
+ after: string,
473
+ ) => {
474
+ const r = expectAll(src, html);
475
+ act();
476
+ await runtime.tick();
477
+ // render() compiled the module three times; the last copy (spa) is
478
+ // the one the globals drive
479
+ expect(noMarkers(r.spa.innerHTML).replace(/ data-key="[^"]*"/g, "")).toBe(
480
+ after,
481
+ );
482
+ };
483
+
484
+ test("a destructured prop follows the state passed in", () =>
485
+ both(
486
+ `let count = $state(1);
487
+ globalThis.__inc = () => count++;
488
+ const Label = ({ text, unit = "x" }) => <b>{text}{unit}</b>;
489
+ export const App = () => <p><Label text={count} /></p>;`,
490
+ "<p><b>1x</b></p>",
491
+ () => g.__inc!(),
492
+ "<p><b>2x</b></p>",
493
+ ));
494
+
495
+ test("props.name reads follow too", () =>
496
+ both(
497
+ `let count = $state(1);
498
+ globalThis.__inc = () => count++;
499
+ const Label = (props) => <b class={props.n > 1 ? "many" : "one"}>{props.n}</b>;
500
+ export const App = () => <p><Label n={count} /></p>;`,
501
+ '<p><b class="one">1</b></p>',
502
+ () => g.__inc!(),
503
+ '<p><b class="many">2</b></p>',
504
+ ));
505
+
506
+ test("bind: on a prop's field writes back to the state", async () => {
507
+ const r = expectAll(
508
+ `let books = $state([{ id: 1, read: false }]);
509
+ const Row = ({ book }) => <li><input type="checkbox" bind:checked={book.read} />{book.read ? "read" : "unread"}</li>;
510
+ export const App = () => <ul>{books.map((b) => <Row key={b.id} book={b} />)}</ul>;`,
511
+ '<ul><li><input type="checkbox">unread</li></ul>',
512
+ );
513
+ for (const host of [r.hydrated, r.spa]) {
514
+ const box = host.querySelector("input")!;
515
+ box.checked = true;
516
+ box.dispatchEvent(new Event("change"));
517
+ await runtime.tick();
518
+ expect(host.querySelector("li")!.textContent).toBe("read");
519
+ }
520
+ });
521
+
522
+ test("a list item passed to a row component stays live", () =>
523
+ both(
524
+ `let books = $state([{ id: 1, read: false }]);
525
+ globalThis.__read = () => { books[0].read = true; };
526
+ const Row = ({ book }) => <li class={book.read ? "read" : ""}>{book.read ? "read" : "unread"}</li>;
527
+ export const App = () => <ul>{books.map((b) => <Row key={b.id} book={b} />)}</ul>;`,
528
+ '<ul><li class="">unread</li></ul>',
529
+ () => g.__read!(),
530
+ '<ul><li class="read">read</li></ul>',
531
+ ));
532
+ });
533
+
534
+ describe("slots side by side", () => {
535
+ test("a prop slot next to a state slot keeps its own markers", async () => {
536
+ const g = globalThis as unknown as Record<string, () => void>;
537
+ const src = `let count = $state(1);
538
+ globalThis.__bump = () => count++;
539
+ const Pair = ({ n }) => {
540
+ let doubled = $derived(n * 2);
541
+ return <p>{n} / {doubled}</p>;
542
+ };
543
+ export const App = () => <Pair n={count} />;`;
544
+ const r = expectAll(src, "<p>1 / 2</p>");
545
+ g.__bump!();
546
+ await runtime.tick();
547
+ expect(noMarkers(r.spa.innerHTML)).toBe("<p>2 / 4</p>");
548
+
549
+ // and on hydrated markup with effects deferred, as grimoire hydrates
550
+ const host = document.createElement("div");
551
+ host.innerHTML = String(compile(src, "ssr")());
552
+ const flags = globalThis as { __sigilDeferEffects?: boolean };
553
+ runtime.pushHydrationNodes(Array.from(host.childNodes));
554
+ flags.__sigilDeferEffects = true;
555
+ try {
556
+ compile(src, "hydrate")();
557
+ } finally {
558
+ flags.__sigilDeferEffects = false;
559
+ runtime.popHydrationNodes();
560
+ }
561
+ runtime.flushDeferredEffects();
562
+ expect(noMarkers(host.innerHTML)).toBe("<p>1 / 2</p>");
563
+ g.__bump!();
564
+ await runtime.tick();
565
+ expect(noMarkers(host.innerHTML)).toBe("<p>2 / 4</p>");
566
+ });
567
+ });
568
+
569
+ describe("state imported from another module", () => {
570
+ function evalModule(
571
+ source: string,
572
+ names: string[],
573
+ inject: Record<string, unknown> = {},
574
+ ) {
575
+ const code = transformSync(source, {
576
+ plugins: ["@babel/plugin-syntax-jsx", [sigilPlugin, { mode: "hydrate" }]],
577
+ filename: "m.tsx",
578
+ })!.code!;
579
+ const body = code
580
+ .replace(/import\s*\{[^}]*\}\s*from\s*["'][^"']+["'];?/g, "")
581
+ .replace(/^export\s+/gm, "");
582
+ return new Function(
583
+ "document",
584
+ ...Object.keys(runtime),
585
+ ...Object.keys(inject),
586
+ `${body};\nreturn { ${names.join(", ")} };`,
587
+ )(globalThis.document, ...Object.values(runtime), ...Object.values(inject));
588
+ }
589
+
590
+ test("JSX reading an imported $store updates", async () => {
591
+ const { cart } = evalModule(`export let cart = $store({ items: [] });`, [
592
+ "cart",
593
+ ]);
594
+ const { App } = evalModule(
595
+ `import { cart } from "./cart";
596
+ export const App = () => (
597
+ <p class={cart.items.length ? "full" : "empty"}>
598
+ {cart.items.length} items: {cart.items.join(",")}
599
+ </p>
600
+ );`,
601
+ ["App"],
602
+ { cart },
603
+ );
604
+ const host = document.createElement("div");
605
+ runtime.pushHydrationNodes([]);
606
+ runtime.insert(host, App());
607
+ runtime.popHydrationNodes();
608
+ expect(noMarkers(host.innerHTML)).toBe('<p class="empty">0 items: </p>');
609
+ cart.items.push("a", "b");
610
+ await runtime.tick();
611
+ expect(noMarkers(host.innerHTML)).toBe('<p class="full">2 items: a,b</p>');
612
+ });
613
+
614
+ test("an imported primitive $store reads as its value", async () => {
615
+ const { count, bump } = evalModule(
616
+ `export let count = $store(0);
617
+ export function bump() { count++; }`,
618
+ ["count", "bump"],
619
+ );
620
+ const { App } = evalModule(
621
+ `import { bump, count } from "./counter";
622
+ export const App = () => {
623
+ let doubled = $derived(count * 2);
624
+ return <p onClick={bump}>{count} / {doubled}</p>;
625
+ };`,
626
+ ["App"],
627
+ { count, bump },
628
+ );
629
+ const host = document.createElement("div");
630
+ runtime.pushHydrationNodes([]);
631
+ runtime.insert(host, App());
632
+ runtime.popHydrationNodes();
633
+ expect(noMarkers(host.innerHTML)).toBe("<p>0 / 0</p>");
634
+ (host.firstChild as HTMLElement).click();
635
+ bump();
636
+ await runtime.tick();
637
+ expect(noMarkers(host.innerHTML)).toBe("<p>2 / 4</p>");
638
+ });
639
+ });