toiljs 0.0.60 → 0.0.62

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 (120) hide show
  1. package/.github/workflows/ci.yml +31 -0
  2. package/CHANGELOG.md +17 -0
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +2 -2
  5. package/build/client/.tsbuildinfo +1 -1
  6. package/build/client/index.d.ts +1 -1
  7. package/build/client/index.js +1 -1
  8. package/build/client/routing/mount.js +11 -26
  9. package/build/client/ssr/markers.d.ts +1 -0
  10. package/build/client/ssr/markers.js +9 -2
  11. package/build/compiler/.tsbuildinfo +1 -1
  12. package/build/compiler/config.d.ts +21 -0
  13. package/build/compiler/config.js +35 -0
  14. package/build/compiler/docs.d.ts +2 -1
  15. package/build/compiler/docs.js +33 -304
  16. package/build/compiler/index.d.ts +13 -0
  17. package/build/compiler/index.js +113 -21
  18. package/build/compiler/template-build.d.ts +23 -3
  19. package/build/compiler/template-build.js +120 -30
  20. package/build/compiler/toil-docs.generated.d.ts +1 -0
  21. package/build/compiler/toil-docs.generated.js +20 -0
  22. package/build/devserver/.tsbuildinfo +1 -1
  23. package/build/devserver/daemon/catalog.d.ts +26 -0
  24. package/build/devserver/daemon/catalog.js +48 -0
  25. package/build/devserver/daemon/cron.d.ts +4 -0
  26. package/build/devserver/daemon/cron.js +50 -0
  27. package/build/devserver/daemon/host.d.ts +37 -0
  28. package/build/devserver/daemon/host.js +94 -0
  29. package/build/devserver/daemon/index.d.ts +34 -0
  30. package/build/devserver/daemon/index.js +241 -0
  31. package/build/devserver/db/catalog.d.ts +2 -1
  32. package/build/devserver/db/catalog.js +44 -44
  33. package/build/devserver/db/database.d.ts +27 -11
  34. package/build/devserver/db/database.js +539 -169
  35. package/build/devserver/db/index.d.ts +1 -1
  36. package/build/devserver/db/index.js +1 -1
  37. package/build/devserver/db/routeKinds.d.ts +8 -0
  38. package/build/devserver/db/routeKinds.js +139 -0
  39. package/build/devserver/db/types.d.ts +64 -1
  40. package/build/devserver/db/types.js +33 -1
  41. package/build/devserver/index.d.ts +10 -0
  42. package/build/devserver/index.js +7 -0
  43. package/build/devserver/mstore/store.d.ts +18 -0
  44. package/build/devserver/mstore/store.js +82 -0
  45. package/build/devserver/runtime/host.d.ts +6 -0
  46. package/build/devserver/runtime/host.js +45 -1
  47. package/build/devserver/runtime/module.d.ts +1 -0
  48. package/build/devserver/runtime/module.js +27 -1
  49. package/build/devserver/server.d.ts +6 -0
  50. package/build/devserver/server.js +59 -0
  51. package/build/devserver/ssr.d.ts +25 -0
  52. package/build/devserver/ssr.js +114 -0
  53. package/build/devserver/wasm/sections.d.ts +2 -0
  54. package/build/devserver/wasm/sections.js +42 -0
  55. package/build/devserver/wasm/surface.d.ts +18 -0
  56. package/build/devserver/wasm/surface.js +41 -0
  57. package/docs/README.md +4 -4
  58. package/docs/auth-todo.md +6 -6
  59. package/docs/caching.md +5 -5
  60. package/docs/cli.md +15 -0
  61. package/docs/client.md +40 -0
  62. package/docs/crypto.md +4 -4
  63. package/docs/data.md +6 -6
  64. package/docs/email.md +28 -28
  65. package/docs/environment.md +10 -10
  66. package/docs/index.md +26 -0
  67. package/docs/ratelimit.md +10 -10
  68. package/docs/routing.md +2 -2
  69. package/docs/server.md +61 -0
  70. package/docs/ssr.md +561 -113
  71. package/docs/styling.md +22 -0
  72. package/docs/time.md +1 -1
  73. package/eslint.config.js +10 -1
  74. package/examples/basic/client/components/Header.tsx +3 -0
  75. package/examples/basic/client/routes/features/actions.tsx +0 -2
  76. package/examples/basic/client/routes/hello.tsx +89 -19
  77. package/examples/basic/client/styles/main.css +48 -0
  78. package/examples/basic/server/SsrHelloRender.ts +97 -0
  79. package/examples/basic/server/main.ts +5 -0
  80. package/examples/basic/server/streams/Echo.ts +49 -0
  81. package/package.json +12 -10
  82. package/scripts/gen-toil-docs.mjs +96 -0
  83. package/src/cli/create.ts +2 -2
  84. package/src/client/index.ts +1 -1
  85. package/src/client/routing/mount.tsx +19 -31
  86. package/src/client/ssr/markers.tsx +33 -4
  87. package/src/compiler/config.ts +88 -2
  88. package/src/compiler/docs.ts +47 -308
  89. package/src/compiler/index.ts +236 -32
  90. package/src/compiler/ssr-codegen.ts +1 -1
  91. package/src/compiler/template-build.ts +271 -53
  92. package/src/compiler/toil-docs.generated.ts +26 -0
  93. package/src/devserver/daemon/catalog.ts +120 -0
  94. package/src/devserver/daemon/cron.ts +87 -0
  95. package/src/devserver/daemon/host.ts +224 -0
  96. package/src/devserver/daemon/index.ts +349 -0
  97. package/src/devserver/db/catalog.ts +61 -53
  98. package/src/devserver/db/database.ts +613 -149
  99. package/src/devserver/db/index.ts +1 -1
  100. package/src/devserver/db/routeKinds.ts +147 -0
  101. package/src/devserver/db/types.ts +65 -2
  102. package/src/devserver/index.ts +12 -0
  103. package/src/devserver/mstore/store.ts +121 -0
  104. package/src/devserver/runtime/host.ts +92 -1
  105. package/src/devserver/runtime/module.ts +35 -1
  106. package/src/devserver/server.ts +101 -0
  107. package/src/devserver/ssr.ts +166 -0
  108. package/src/devserver/wasm/sections.ts +59 -0
  109. package/src/devserver/wasm/surface.ts +88 -0
  110. package/test/daemon-build.test.ts +198 -0
  111. package/test/daemon-catalog.test.ts +265 -0
  112. package/test/daemon-emulation.test.ts +216 -0
  113. package/test/devserver-database.test.ts +396 -5
  114. package/test/email-preview.test.ts +6 -1
  115. package/test/fixtures/daemon-app.ts +56 -0
  116. package/test/global-setup.ts +17 -0
  117. package/test/ssr-hydration.test.tsx +107 -0
  118. package/test/ssr-render.test.ts +96 -27
  119. package/test/ssr-template.test.tsx +47 -2
  120. package/vitest.config.ts +3 -0
@@ -0,0 +1,107 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Real-hydration test: drive the actual build (`extractRouteTemplate`, which now
4
+ * renders with `renderToString`) -> splice -> `hydrateRoot` path in jsdom and
5
+ * assert there is NO hydration mismatch and the content is present. This is the
6
+ * failure users hit ("server rendered HTML didn't match the client"), and it
7
+ * covers the three things that broke it:
8
+ * - "text + hole + text" (`Hello, <Hole>{name}</Hole>!`): needs the `<!-- -->`
9
+ * text-boundary markers `renderToString` emits.
10
+ * - an `<img>`, whose React 19 auto-preload `<link>` must be kept OUT of `#root`.
11
+ * - an `<Island>`, which must be empty on the first (hydration) render.
12
+ */
13
+ import { act } from 'react';
14
+ import { hydrateRoot } from 'react-dom/client';
15
+ import { describe, expect, it, vi } from 'vitest';
16
+
17
+ import { Hole, Island, RawHtml, __setSsrBuild } from '../src/client/ssr/markers';
18
+ import { extractRouteTemplate } from '../src/compiler/template-build';
19
+ import { reactEscapeHtml, spliceTemplate } from '../src/compiler/template';
20
+
21
+ const NAME = 'world';
22
+ const BLURB = 'Rendered at the <strong>edge</strong>.';
23
+
24
+ function Page(): React.ReactElement {
25
+ return (
26
+ <main>
27
+ <img src="/images/logo.svg" alt="logo" width={28} height={28} />
28
+ <h1>
29
+ Hello, <Hole id="name">{NAME}</Hole>!
30
+ </h1>
31
+ <p>
32
+ <RawHtml id="blurb" html={BLURB} as="span" />
33
+ </p>
34
+ <Island>
35
+ <span className="isle">island-only</span>
36
+ </Island>
37
+ </main>
38
+ );
39
+ }
40
+
41
+ const SHELL =
42
+ '<!doctype html><html><head><title>t</title></head><body><div id="root"></div></body></html>';
43
+
44
+ /** Build the template (renderToString + strip), splice per-slot values, return #root inner HTML. */
45
+ function serverRootHtml(): string {
46
+ const art = extractRouteTemplate({
47
+ name: 'hyd',
48
+ Page,
49
+ layouts: [],
50
+ loaderData: null,
51
+ loaderContext: null,
52
+ setSsrBuild: __setSsrBuild,
53
+ shell: SHELL,
54
+ });
55
+ const valueFor: Record<number, Buffer> = {
56
+ 0: Buffer.from(reactEscapeHtml(NAME), 'utf8'), // name (text)
57
+ 1: Buffer.from(BLURB, 'utf8'), // blurb (raw)
58
+ };
59
+ const nSlots = art.slotsBin.readUInt16LE(44);
60
+ const inserts: { offset: number; value: Buffer }[] = [];
61
+ let o = 46;
62
+ for (let i = 0; i < nSlots; i++) {
63
+ inserts.push({ offset: art.slotsBin.readUInt32LE(o), value: valueFor[art.slotsBin.readUInt16LE(o + 4)] });
64
+ o += 8;
65
+ }
66
+ const full = spliceTemplate(art.tmpl, inserts).toString('utf8');
67
+ const m = /<div id="root">([\s\S]*?)<\/div><template id="__toil_ssr">/.exec(full);
68
+ if (!m) throw new Error('could not isolate #root');
69
+ return m[1];
70
+ }
71
+
72
+ describe('ssr hydration (real hydrateRoot, no mismatch)', () => {
73
+ it('hydrates the spliced server markup cleanly and reveals the island after mount', async () => {
74
+ const rootInner = serverRootHtml();
75
+ expect(rootInner).not.toContain('rel="preload"'); // preload hoisted to <head>, not #root
76
+ expect(rootInner).not.toContain('island-only'); // island empty server-side
77
+ expect(rootInner).toContain('Hello, '); // text hole filled
78
+ expect(rootInner).toContain('<strong>edge</strong>'); // raw hole verbatim
79
+
80
+ document.body.innerHTML = `<div id="root">${rootInner}</div>`;
81
+ const rootEl = document.getElementById('root')!;
82
+
83
+ const recoverable: string[] = [];
84
+ const consoleErrors: string[] = [];
85
+ const spy = vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => {
86
+ consoleErrors.push(a.map(String).join(' '));
87
+ });
88
+ try {
89
+ await act(async () => {
90
+ hydrateRoot(rootEl, <Page />, {
91
+ onRecoverableError: (e) => recoverable.push(String(e)),
92
+ });
93
+ });
94
+ } finally {
95
+ spy.mockRestore();
96
+ }
97
+
98
+ const noise = /hydrat|did not match|server rendered|didn't match/i;
99
+ expect(recoverable.filter((e) => noise.test(e))).toEqual([]);
100
+ expect(consoleErrors.filter((e) => noise.test(e))).toEqual([]);
101
+
102
+ // Content survived hydration (not regenerated/blanked); the island revealed after mount.
103
+ expect(rootEl.querySelector('h1')?.textContent).toBe('Hello, world!');
104
+ expect(rootEl.querySelector('.isle')?.textContent).toBe('island-only');
105
+ expect(rootEl.querySelector('img')?.getAttribute('src')).toBe('/images/logo.svg');
106
+ });
107
+ });
@@ -1,11 +1,19 @@
1
1
  /**
2
2
  * End-to-end guest render: drive the real ToilScript-compiled `render` export
3
- * of the SSR example wasm, decode its values envelope, and confirm the guest
4
- * produces exactly the bytes the Rust host's `decode_values`/`assemble` expects
5
- * (the same wire format proven on the host side in
3
+ * of the basic example's server wasm, decode its values envelope, and confirm
4
+ * the guest produces exactly the bytes the Rust host's `decode_values`/`assemble`
5
+ * expects (the same wire format proven on the host side in
6
6
  * `toil-backend/src/host/template/assemble.rs`). Closing the loop with the
7
7
  * golden byte-identity test (`ssr-template.test.tsx`), this proves the full
8
8
  * chain: guest values -> splice -> React-identical HTML.
9
+ *
10
+ * SSR is part of the normal (single-wasm) build now: the example's `/hello`
11
+ * route opts in with `export const ssr = true`, the build extracts its template
12
+ * into `build/client/_ssr/hello.{tmpl,slots,slots.ts}`, and the server
13
+ * `render` (`examples/basic/server/SsrHelloRender.ts`) fills the holes. We drive
14
+ * the same `build/server/release.wasm` the dev server and edge run, and pin the
15
+ * expected coherence hash to the generated `templates.json` so the guest and the
16
+ * deployed template are proven to agree.
9
17
  */
10
18
  import fs from 'node:fs';
11
19
  import path from 'node:path';
@@ -16,10 +24,14 @@ import { describe, expect, it } from 'vitest';
16
24
  import { WasmServerModule } from '../src/devserver/index.js';
17
25
  import { spliceTemplate } from '../src/compiler/template.js';
18
26
 
19
- const SSR_WASM = path.resolve(
27
+ const EXAMPLE = path.resolve(
20
28
  path.dirname(fileURLToPath(import.meta.url)),
21
- '../examples/basic/build/server/ssr.wasm',
29
+ '../examples/basic',
22
30
  );
31
+ /** Single-wasm SSR: the server `render` lives in the normal server wasm. */
32
+ const SERVER_WASM = path.join(EXAMPLE, 'build/server/release.wasm');
33
+ const SSR_DIR = path.join(EXAMPLE, 'build/client/_ssr');
34
+ const TEMPLATES = path.join(SSR_DIR, 'templates.json');
23
35
 
24
36
  interface DecodedSlot {
25
37
  slotId: number;
@@ -72,9 +84,25 @@ function decodeValues(buf: Uint8Array): DecodedValues {
72
84
  return { status, hash, headers, slots };
73
85
  }
74
86
 
75
- describe.skipIf(!fs.existsSync(SSR_WASM))('edge SSR guest render (real wasm)', () => {
87
+ /** The hash the build pinned for the `/hello` template (deploy-skew guard). */
88
+ function helloTemplateHash(): Buffer {
89
+ const index = JSON.parse(fs.readFileSync(TEMPLATES, 'utf8')) as {
90
+ route: string;
91
+ name: string;
92
+ hash: string;
93
+ }[];
94
+ const hello = index.find((t) => t.route === '/hello');
95
+ if (!hello) throw new Error('no /hello template in templates.json');
96
+ return Buffer.from(hello.hash, 'hex');
97
+ }
98
+
99
+ // Skip cleanly when the example has not been built (no toolchain in CI): the
100
+ // build is what produces both the wasm and the template artifacts.
101
+ const built = fs.existsSync(SERVER_WASM) && fs.existsSync(TEMPLATES);
102
+
103
+ describe.skipIf(!built)('edge SSR guest render (real single-wasm build)', () => {
76
104
  const load = (): WasmServerModule => {
77
- const m = new WasmServerModule(SSR_WASM);
105
+ const m = new WasmServerModule(SERVER_WASM);
78
106
  m.refresh();
79
107
  return m;
80
108
  };
@@ -86,36 +114,77 @@ describe.skipIf(!fs.existsSync(SSR_WASM))('edge SSR guest render (real wasm)', (
86
114
  body: new Uint8Array(0),
87
115
  });
88
116
 
89
- it('produces a values envelope with the compiled-in hash and escaped holes', () => {
117
+ it('produces a values envelope with the deployed template hash and escaped holes', () => {
90
118
  const d = decodeValues(render(load(), '/hello'));
91
119
  expect(d.status).toBe(200);
92
- // The HASH baked into greeting.slots.ts: bytes 0x00..0x1f.
93
- expect([...d.hash]).toEqual(Array.from({ length: 32 }, (_, i) => i));
120
+ // The guest's compiled-in HASH must equal the deployed template's hash,
121
+ // or the host rejects the response as a deploy skew.
122
+ expect(d.hash.equals(helloTemplateHash())).toBe(true);
94
123
 
124
+ // Top-level slots in document order: name (text), blurb (raw), services (repeat).
95
125
  expect(d.slots.map((s) => `${s.slotId}:${s.kind}`)).toEqual([
96
- '0:0', // greeting, text
97
- '1:3', // count, repeat
126
+ '0:0', // name, text
127
+ '1:1', // blurb, raw
128
+ '2:3', // services, repeat
98
129
  ]);
99
- // Text hole: React-escaped (& -> &amp;, <> -> entities, mirroring escape.ts).
100
- expect(d.slots[0].value.toString('utf8')).toBe('world &amp; &lt;friends&gt;');
101
- // Repeat hole: three stamped rows, each escaped.
130
+ // Text hole: the default greeting target.
131
+ expect(d.slots[0].value.toString('utf8')).toBe('world');
132
+ // Raw hole: inserted verbatim (NOT escaped).
102
133
  expect(d.slots[1].value.toString('utf8')).toBe(
103
- '<li>a &amp; b</li><li>&lt;c&gt;</li><li>d</li>',
134
+ 'Rendered at the <strong>edge</strong> from a tiny values envelope.',
104
135
  );
136
+ // Repeat hole: three stamped rows, each nested hole React-escaped.
137
+ expect(d.slots[2].value.toString('utf8')).toBe(
138
+ '<li><strong>record</strong><span class="hello-region">us-east</span></li>' +
139
+ '<li><strong>unique</strong><span class="hello-region">eu-west</span></li>' +
140
+ '<li><strong>counter</strong><span class="hello-region">ap-south</span></li>',
141
+ );
142
+ });
143
+
144
+ it('escapes a text hole derived from the request (?name=)', () => {
145
+ const d = decodeValues(render(load(), '/hello?name=A<b>%26"x'));
146
+ expect(d.status).toBe(200);
147
+ // setText React-escapes: & -> &amp;, <> -> entities, " -> &quot;.
148
+ // (The query value arrives as the raw bytes after `name=`; only `%26`
149
+ // is a literal here, so the guest sees `A<b>&"x` after the `&` split is
150
+ // accounted for — assert the escaping shape rather than the exact bytes.)
151
+ const v = d.slots[0].value.toString('utf8');
152
+ expect(v).not.toContain('<b>');
153
+ expect(v).toContain('&lt;');
105
154
  });
106
155
 
107
- it('splices into a template exactly (full guest -> host -> HTML chain)', () => {
156
+ it('splices into the real built template exactly (guest -> host -> HTML)', () => {
108
157
  const d = decodeValues(render(load(), '/hello'));
109
- // A template with a greeting hole and a repeat region, holes removed.
110
- const tmpl = Buffer.from('<h1>Hello </h1><ul></ul>', 'utf8');
111
- const greetingOffset = '<h1>Hello '.length;
112
- const repeatOffset = '<h1>Hello </h1><ul>'.length;
113
- const out = spliceTemplate(tmpl, [
114
- { offset: greetingOffset, value: Buffer.from(d.slots[0].value) },
115
- { offset: repeatOffset, value: Buffer.from(d.slots[1].value) },
116
- ]);
117
- expect(out.toString('utf8')).toBe(
118
- '<h1>Hello world &amp; &lt;friends&gt;</h1><ul><li>a &amp; b</li><li>&lt;c&gt;</li><li>d</li></ul>',
158
+ const tmpl = fs.readFileSync(path.join(SSR_DIR, 'hello.tmpl'));
159
+ const slotsBin = fs.readFileSync(path.join(SSR_DIR, 'hello.slots'));
160
+
161
+ // Read the top-level slot offsets straight from the .slots manifest
162
+ // (header is 46 bytes; each entry is offset u32, id u16, kind u8, rsvd u8).
163
+ const nSlots = slotsBin.readUInt16LE(44);
164
+ const byId = new Map(d.slots.map((s) => [s.slotId, s.value]));
165
+ const inserts: { offset: number; value: Buffer }[] = [];
166
+ let o = 46;
167
+ for (let i = 0; i < nSlots; i++) {
168
+ const offset = slotsBin.readUInt32LE(o);
169
+ const id = slotsBin.readUInt16LE(o + 4);
170
+ inserts.push({ offset, value: Buffer.from(byId.get(id)!) });
171
+ o += 8;
172
+ }
173
+ const out = spliceTemplate(tmpl, inserts).toString('utf8');
174
+
175
+ // The spliced section is well-formed and carries every filled hole. The
176
+ // `<!-- -->` around `world` are React's text-boundary markers (renderToString
177
+ // emits them so hydrateRoot can align the `name` hole between "Hello, " and "!").
178
+ expect(out).toContain(
179
+ '<section class="hello"><h1>Hello, <!-- -->world<!-- -->!</h1>' +
180
+ '<p class="hello-blurb"><span>Rendered at the <strong>edge</strong> ' +
181
+ 'from a tiny values envelope.</span></p>' +
182
+ '<h2>Service snapshot</h2>' +
183
+ '<ul class="hello-services">' +
184
+ '<li><strong>record</strong><span class="hello-region">us-east</span></li>' +
185
+ '<li><strong>unique</strong><span class="hello-region">eu-west</span></li>' +
186
+ '<li><strong>counter</strong><span class="hello-region">ap-south</span></li>' +
187
+ '</ul></section>',
119
188
  );
120
189
  });
121
190
 
@@ -20,7 +20,7 @@ import path from 'node:path';
20
20
  import { renderToStaticMarkup } from 'react-dom/server';
21
21
  import { describe, expect, it } from 'vitest';
22
22
 
23
- import { Hole, Island, RawHtml, Repeat, __setSsrBuild } from '../src/client/ssr/markers';
23
+ import { Hole, Island, RawHtml, Repeat, attr, __setSsrBuild } from '../src/client/ssr/markers';
24
24
  import { LoaderDataContext, useLoaderData } from '../src/client/routing/loader';
25
25
  import {
26
26
  extractRouteTemplate,
@@ -265,6 +265,49 @@ describe('ssr guest codegen (Slot enum + HASH)', () => {
265
265
  });
266
266
  });
267
267
 
268
+ describe('ssr attribute holes (attr())', () => {
269
+ it('is transparent in the browser and a sentinel under the build extractor', () => {
270
+ // Browser (default): passes the value through unchanged.
271
+ expect(attr('link', '/u/ada')).toBe('/u/ada');
272
+ // Build: emits a PUA sentinel token (start codepoint U+E000) carrying the id.
273
+ __setSsrBuild(true);
274
+ try {
275
+ const tok = attr('link', '/u/ada');
276
+ expect(tok).not.toBe('/u/ada');
277
+ expect(tok.charCodeAt(0)).toBe(0xe000);
278
+ expect(tok).toContain('link');
279
+ } finally {
280
+ __setSsrBuild(false);
281
+ }
282
+ });
283
+
284
+ it('extracts an attr slot whose guest stamp reproduces React attribute output byte-for-byte', () => {
285
+ // Render with the attr() hole in an attribute position, in build mode.
286
+ __setSsrBuild(true);
287
+ let built: string;
288
+ try {
289
+ built = renderToStaticMarkup(<a href={attr('link', 'IGNORED_AT_BUILD')}>x</a>);
290
+ } finally {
291
+ __setSsrBuild(false);
292
+ }
293
+ const { tmpl, slots } = extractFromHtml(built);
294
+ expect(slots).toHaveLength(1);
295
+ expect(slots[0]).toMatchObject({ id: 'link', kind: 'attr' });
296
+ expect(kindByte(slots[0].kind)).toBe(2);
297
+ // The .tmpl carries the attribute with the hole stripped to an empty value.
298
+ expect(tmpl.toString('utf8')).toBe('<a href="">x</a>');
299
+
300
+ // Guest stamp: setAttr React-escapes (identical to text); splice at the offset.
301
+ const value = '/u/ada?q="a"&b<c>';
302
+ const stamped = spliceTemplate(tmpl, [
303
+ { offset: slots[0].offset, value: Buffer.from(reactEscapeHtml(value), 'utf8') },
304
+ ]).toString('utf8');
305
+
306
+ // Byte-identical to what React renders for the same attribute value (clean hydration).
307
+ expect(stamped).toBe(renderToStaticMarkup(<a href={value}>x</a>));
308
+ });
309
+ });
310
+
268
311
  describe('ssr build orchestration', () => {
269
312
  const SHELL =
270
313
  '<!doctype html><html><head><title>t</title></head><body><div id="root"></div>' +
@@ -313,8 +356,10 @@ describe('ssr build orchestration', () => {
313
356
 
314
357
  const tmpl = art.tmpl.toString('utf8');
315
358
  // Full document with the layout + page scaffold spliced into #root, holes removed.
359
+ // The `<!-- -->` after `@` is React's text-boundary marker (renderToString emits
360
+ // it so hydrateRoot can align the `username` hole); the hole text itself is stripped.
316
361
  expect(tmpl).toContain(
317
- '<div id="root"><div class="app"><main><h1>@</h1><div></div><ul></ul></main></div></div>',
362
+ '<div id="root"><div class="app"><main><h1>@<!-- --></h1><div></div><ul></ul></main></div></div>',
318
363
  );
319
364
  expect(tmpl).toContain('<template id="__toil_ssr"></template>');
320
365
  expect(tmpl).toContain('/assets/app-abc123.js'); // bootstrap script preserved
package/vitest.config.ts CHANGED
@@ -6,6 +6,9 @@ export default defineConfig({
6
6
  test: {
7
7
  globals: true,
8
8
  environment: 'node',
9
+ // Generate src/compiler/toil-docs.generated.ts (gitignored, imported by
10
+ // docs.ts) before the suite, so a fresh checkout can test without a build.
11
+ globalSetup: ['./test/global-setup.ts'],
9
12
  include: ['test/**/*.test.ts', 'test/**/*.test.tsx', 'test/**/*.spec.ts'],
10
13
  // test/assembly holds toilscript specs run by as-pect, not vitest.
11
14
  exclude: [...configDefaults.exclude, 'test/assembly/**'],