@zerotal/inertia 1.9.0 → 1.11.0

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/CHANGELOG.md CHANGED
@@ -8,6 +8,84 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.11.0] — 2026-08-31
12
+
13
+ ### Added
14
+
15
+ - **`@zerotal/inertia/testing` — `renderPage()`.** `assertInertia("home")` proves the
16
+ _server_ named a component and handed it props. It proves nothing about the
17
+ component, and a page can throw on its first paint while every such test passes: the
18
+ route answers `200`, the payload is correct, and the failure happens in a browser
19
+ the suite never opened. An app shipped a blank page to production with **614 passing
20
+ tests** exactly that way — a layout callback read `page.props`, which the callback
21
+ is not given.
22
+
23
+ `renderPage(Component, props, { shared })` builds the tree through Inertia's own
24
+ `<App>`, so `usePage()`, `<Head>` and a persistent layout all behave as they do in
25
+ the browser, and lets whatever it throws escape. It is not a DOM — `useEffect` does
26
+ not run — which is the point: it proves the tree _builds_, which is what nothing
27
+ else checked.
28
+
29
+ ### Documented
30
+
31
+ - **[Persistent layouts](/docs/inertia/rendering#persistent-layouts)**, which this
32
+ package documented nowhere — not in the README, not in `api-surface.md`. The
33
+ callback is handed the page **element**, not the page props, so the natural
34
+ `(page) => <Layout search={page.props.search}>{page}</Layout>` throws on the first
35
+ paint and only in a browser. The page now shows the `usePage()` form and says why
36
+ the wrong one typechecks.
37
+
38
+ ### Fixed
39
+
40
+ - **`zt inertia:build` fails when it produces nothing.** A build could report success
41
+ with zero artefacts, and that is the shape that reaches production: the deploy sees
42
+ exit 0, restarts, and serves a page with no script and no stylesheet. The health
43
+ check passes — the server is fine and the HTML is fine, there is just nothing in
44
+ it. An app had to assert the files exist in its own deploy script to catch it.
45
+
46
+ ## [1.10.0] — 2026-08-30
47
+
48
+ ### Fixed
49
+
50
+ - **React SSR emits the page's `<Head>` tags.** The React branch rendered the page
51
+ component directly — `createElement(Page, props)` — which produces correct body
52
+ markup and drops every `<Head>` on the page. `<Head>` renders nothing; it reports
53
+ its children to a head manager it reads from context, and rendering the component
54
+ alone puts none there. So a page that set a title, a description and an og: card
55
+ contributed all three to nothing, and the server sent the template's `<head>`
56
+ verbatim. Nothing failed and nothing logged — the page was perfect in a browser,
57
+ where React had run — and a link pasted into a chat was a grey rectangle with a
58
+ domain in it.
59
+
60
+ Both server-rendered paths (`inertiaStream()` and `POST /__ssr`) now render through
61
+ `@inertiajs/react`'s `<App>`, which installs the head manager, and splice what comes
62
+ back into the template's `<head>`. **React apps using SSR must have
63
+ `@inertiajs/react` installed** — the same adapter the browser entry point already
64
+ uses; a missing one is now a named error rather than a silent omission.
65
+
66
+ - **An injected head tag replaces the template's, rather than being appended after
67
+ it.** This applies to Vue as well, where head injection did work: the templates all
68
+ ship a `<title>`, and a document with two titles is a document with the _first_
69
+ one. The page's tag was present, correct and ignored. A rendered `<title>` now
70
+ replaces the template's, and a `<meta>` replaces the one with the same `name` or
71
+ `property`; anything with no counterpart is appended before `</head>`.
72
+
73
+ - **The React SSR root is marked `data-server-rendered`, and the page script comes
74
+ first.** The streaming branch emitted an unmarked `<div id="app">`, so the client
75
+ discarded the server's markup and rendered the page a second time — paying for SSR
76
+ and then throwing it away. `POST /__ssr` also returns the same body shape as the Vue
77
+ branch now (the whole Inertia root, ready to drop into a template) instead of the
78
+ bare component HTML.
79
+
80
+ ### Documented
81
+
82
+ - **["What a crawler sees"](/docs/inertia/ssr#what-a-crawler-sees)** — `inertia()` does
83
+ not server-render the component at all, which is the normal Inertia arrangement and
84
+ worth saying out loud: the served document is a `<title>` and a JSON blob. The page
85
+ names which readers run JavaScript (browsers, search engines on a second pass) and
86
+ which do not (every link-preview scraper, `curl`, most reader tools), and the three
87
+ ways to give the second group something to read.
88
+
11
89
  ## [1.9.0] — 2026-08-29
12
90
 
13
91
  ### Fixed
package/api-surface.md CHANGED
@@ -319,3 +319,13 @@ type RenderArgs = [props?: Record<string, unknown>]
319
319
  type RenderProps = { [x: string]: unknown;}
320
320
 
321
321
  type RouteTable = Readonly<Record<string, string>> | ReadonlyMap<string, string>
322
+
323
+ ## ./testing `(./src/testing.ts)`
324
+
325
+ function renderPage = (component: unknown, props?: Record<string, unknown>, options?: RenderPageOptions) => Promise<string>
326
+
327
+ interface RenderPageOptions = {
328
+ component?: string | undefined
329
+ shared?: Record<string, unknown> | undefined
330
+ url?: string | undefined
331
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/inertia",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -8,7 +8,8 @@
8
8
  "main": "./src/index.ts",
9
9
  "types": "./src/index.ts",
10
10
  "exports": {
11
- ".": "./src/index.ts"
11
+ ".": "./src/index.ts",
12
+ "./testing": "./src/testing.ts"
12
13
  },
13
14
  "files": [
14
15
  "CHANGELOG.md",
@@ -33,7 +34,7 @@
33
34
  "typecheck": "tsc --noEmit"
34
35
  },
35
36
  "dependencies": {
36
- "@zerotal/core": "1.9.0"
37
+ "@zerotal/core": "1.11.0"
37
38
  },
38
39
  "peerDependencies": {
39
40
  "react": "^18 || ^19",
@@ -60,6 +61,7 @@
60
61
  }
61
62
  },
62
63
  "devDependencies": {
64
+ "@inertiajs/react": "^3.7.0",
63
65
  "react": "^19.2.7",
64
66
  "react-dom": "^19.2.7",
65
67
  "typescript": "^5.8.0"
@@ -79,6 +79,20 @@ export class InertiaBuildCommand extends Command {
79
79
  throw new Error("Frontend build failed.");
80
80
  }
81
81
 
82
+ // `success` with no artefacts is not a build, and it is the shape that reaches
83
+ // production: a deploy runs this, sees exit 0, restarts, and serves a page with
84
+ // no script and no stylesheet. The health check passes — the server is fine, the
85
+ // HTML is fine, there is simply nothing in it. An app had to assert the files
86
+ // exist in its own deploy script to catch it, which is the framework's job.
87
+ if (result.outputs.length === 0) {
88
+ throw new Error(
89
+ `Frontend build reported success and produced no files ` +
90
+ `(entry point: resources/js/app.tsx). An empty output directory serves a ` +
91
+ `page with no script and no stylesheet, which a health check cannot tell ` +
92
+ `from a working one.`,
93
+ );
94
+ }
95
+
82
96
  // Chunks are named after their content, so the ones this build replaced
83
97
  // would otherwise stay behind — and ship.
84
98
  //
package/src/global.d.ts CHANGED
@@ -25,6 +25,8 @@ declare module 'react' {
25
25
 
26
26
  declare module 'react-dom/client' {
27
27
  export function createRoot(container: Element | null): { render(el: unknown): void };
28
+ /** Attach React to markup the server already rendered, rather than replacing it. */
29
+ export function hydrateRoot(container: Element, el: unknown): { render(el: unknown): void };
28
30
  }
29
31
 
30
32
  declare module 'react-dom/server' {
package/src/inertia.ts CHANGED
@@ -10,7 +10,9 @@ import { checkPropBoundary } from "./props/propBoundary.ts";
10
10
  import { readHistoryFlags } from "./historyState.ts";
11
11
  import { allSharedKeys } from "./share.ts";
12
12
  import { recordPage } from "./devtools/recorder.ts";
13
- import { resolvePageModule, renderInertiaPage } from "./ssr/renderPage.ts";
13
+ import { resolvePageModule, renderInertiaPage, _prepareReactRender } from "./ssr/renderPage.ts";
14
+ import { injectHead } from "./ssr/head.ts";
15
+ import { pageScript, rootOpen, ROOT_CLOSE } from "./pageScript.ts";
14
16
  import type { PageObject } from "./types.ts";
15
17
  import type { PageTarget, RenderArgs } from "./pages.ts";
16
18
 
@@ -274,38 +276,32 @@ async function _inertiaStream(component: string, props: Record<string, unknown>)
274
276
  };
275
277
 
276
278
  if (framework === "react") {
277
- // React's stream is just the component's inner HTML, so Zerotal wraps it in
278
- // the app root and serialises the pageObject into the data-page script.
279
- const safeJson = JSON.stringify(pageObject)
280
- .replace(/</g, "\\u003c")
281
- .replace(/>/g, "\\u003e")
282
- .replace(/&/g, "\\u0026")
283
- .replace(/\//g, "\\/");
284
- const openTag = `<div id="app">`;
285
- const closeBlock = `</div>\n <script type="application/json" data-page="app">${safeJson}</script>`;
286
-
287
- // Specifiers via variables, not literals. React is an *optional* peer — a Vue
288
- // app never installs it but a literal `import("react")` is still resolved by
289
- // TypeScript, so type-checking a Vue project failed on modules it will never
290
- // have. Both results are cast below, so nothing is lost by hiding the
291
- // specifier from the resolver; this branch only runs when the app is React.
292
- const reactSpecifier = "react";
279
+ // React's stream is just the component's inner HTML, so Zerotal writes the SSR
280
+ // root around it the `<script data-page>` first, so the client's boot payload
281
+ // is in the browser's hands before the component finishes arriving.
282
+ //
283
+ // The head tags cost nothing to wait for. `renderToReadableStream` already
284
+ // resolves only once the shell is ready, which this branch already awaited, and
285
+ // `<Head>` reports to the head manager synchronously during that render. So by
286
+ // the time there is a first byte to send, the page's title and meta are known
287
+ // and can go into the `<head>` that is about to be flushed.
288
+ const { element, head } = await _prepareReactRender(pageObject, modPath);
289
+
290
+ // Specifier via a variable, not a literal. `react-dom/server` is an *optional*
291
+ // peer a Vue app never installs it but a literal `import()` is still
292
+ // resolved by TypeScript, so type-checking a Vue project failed on a module it
293
+ // will never have. The result is cast below; this branch only runs on React.
293
294
  const reactServerSpecifier = "react-dom/server";
295
+ const serverMod = (await import(reactServerSpecifier)) as {
296
+ renderToReadableStream(el: unknown): Promise<ReadableStream<Uint8Array>>;
297
+ };
294
298
 
295
- const [reactMod, serverMod, pageMod] = await Promise.all([
296
- import(reactSpecifier) as Promise<{ createElement(type: unknown, props: unknown): unknown }>,
297
- import(reactServerSpecifier) as Promise<{
298
- renderToReadableStream(el: unknown): Promise<ReadableStream<Uint8Array>>;
299
- }>,
300
- import(modPath) as Promise<{ default: unknown }>,
301
- ]);
302
-
303
- const element = reactMod.createElement(pageMod.default, pageObject.props);
304
299
  const reactStream = await serverMod.renderToReadableStream(element);
300
+ const openBlock = injectHead(prefix, head()) + pageScript(pageObject) + rootOpen(true);
305
301
 
306
302
  const readable = new ReadableStream<Uint8Array>({
307
303
  async start(controller) {
308
- controller.enqueue(encoder.encode(prefix + openTag));
304
+ controller.enqueue(encoder.encode(openBlock));
309
305
  const reader = reactStream.getReader();
310
306
  try {
311
307
  while (true) {
@@ -316,7 +312,7 @@ async function _inertiaStream(component: string, props: Record<string, unknown>)
316
312
  } finally {
317
313
  reader.releaseLock();
318
314
  }
319
- controller.enqueue(encoder.encode(closeBlock + suffix));
315
+ controller.enqueue(encoder.encode(ROOT_CLOSE + suffix));
320
316
  controller.close();
321
317
  },
322
318
  });
@@ -325,18 +321,16 @@ async function _inertiaStream(component: string, props: Record<string, unknown>)
325
321
  return;
326
322
  }
327
323
 
328
- // Vue: @inertiajs/vue3's SSR mode already emits the full
329
- // `<div id="app" data-page="…">…</div>` root (the complete pageObject is
330
- // serialised into data-page), so inject it directly in place of the
331
- // placeholder no extra app-div wrapper or data-page script. Any <Head>
332
- // tags are injected into <head>.
324
+ // Vue: @inertiajs/vue3's SSR mode already emits the full Inertia root — the
325
+ // `<script data-page>` and the `<div id="app">` around the rendered component
326
+ // so inject it directly in place of the placeholder. Any <Head> tags are spliced
327
+ // into <head>, replacing the template's own title and meta rather than being
328
+ // appended after them (a second <title> is a <title> the browser ignores).
333
329
  const { body, head } = await renderInertiaPage(pageObject, modPath, framework);
334
- const prefixWithHead =
335
- head.length > 0 ? prefix.replace("</head>", `${head.join("")}</head>`) : prefix;
336
330
 
337
331
  const readable = new ReadableStream<Uint8Array>({
338
332
  start(controller) {
339
- controller.enqueue(encoder.encode(prefixWithHead + body + suffix));
333
+ controller.enqueue(encoder.encode(injectHead(prefix, head) + body + suffix));
340
334
  controller.close();
341
335
  },
342
336
  });
@@ -435,19 +429,15 @@ async function _inertia(component: string, props: Record<string, unknown>): Prom
435
429
  throw new InertiaTemplateNotLoadedError();
436
430
  }
437
431
 
438
- // Inject pageObject into the HTML template.
439
- // Escape characters that would break HTML parsing inside a script tag.
440
- // </script> <\/script>, < <, > >
441
- const safeJson = JSON.stringify(pageObject)
442
- .replace(/</g, "\\u003c")
443
- .replace(/>/g, "\\u003e")
444
- .replace(/&/g, "\\u0026")
445
- .replace(/\//g, "\\/");
446
-
432
+ // Inject pageObject into the HTML template. The root is empty — this path does
433
+ // not server-render the component, so it is deliberately *not* marked
434
+ // `data-server-rendered`: that flag tells the client to hydrate, and hydrating an
435
+ // empty div is a mismatch on every page. See `inertiaStream()` for the rendered
436
+ // form, and the "What a crawler sees" section of the Inertia docs for what this
437
+ // response contains.
447
438
  const html = _bustAssets(_htmlTemplate).replace(
448
439
  "<!-- @inertia -->",
449
- `<div id="app"></div>\n ` +
450
- `<script type="application/json" data-page="app">${safeJson}</script>`,
440
+ `${rootOpen(false)}${ROOT_CLOSE}\n ${pageScript(pageObject)}`,
451
441
  );
452
442
 
453
443
  ctx.response = new Response(html, {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The `<script data-page>` tag the Inertia client boots from, and the root it
3
+ * mounts into.
4
+ *
5
+ * Inertia v3 reads the initial page from
6
+ * `script[data-page="app"][type="application/json"]` — a script tag rather than an
7
+ * attribute on the root div, so a large page object does not have to survive
8
+ * attribute escaping. This module owns that markup so the three places that emit
9
+ * it (`inertia()`, `inertiaStream()`, and the `/__ssr` endpoint) cannot drift
10
+ * apart, which is how one of them ends up with a shape the client cannot read.
11
+ *
12
+ * @module
13
+ */
14
+
15
+ /** The element id the Inertia client mounts into, and the `data-page` key it looks up. */
16
+ export const APP_ID = "app";
17
+
18
+ /**
19
+ * Serialise a page object for embedding in a `<script>` block.
20
+ *
21
+ * `JSON.stringify` alone is not safe here: the payload is arbitrary application
22
+ * data, and a string containing `</script>` ends the block early — everything
23
+ * after it is parsed as HTML. Escaping `<`, `>`, `&` and `/` to their `\uXXXX`
24
+ * forms keeps the JSON valid (JSON.parse decodes them) while leaving nothing in
25
+ * the output a parser can treat as markup.
26
+ *
27
+ * @param page - The Inertia page object.
28
+ * @returns JSON with every character that could break out of a script block escaped.
29
+ */
30
+ export function serialisePage(page: unknown): string {
31
+ return JSON.stringify(page)
32
+ .replace(/</g, "\\u003c")
33
+ .replace(/>/g, "\\u003e")
34
+ .replace(/&/g, "\\u0026")
35
+ .replace(/\//g, "\\/");
36
+ }
37
+
38
+ /**
39
+ * The `<script data-page>` tag carrying the page object.
40
+ *
41
+ * @param page - The Inertia page object.
42
+ */
43
+ export function pageScript(page: unknown): string {
44
+ return `<script type="application/json" data-page="${APP_ID}">${serialisePage(page)}</script>`;
45
+ }
46
+
47
+ /**
48
+ * The opening tag of the mount root.
49
+ *
50
+ * `data-server-rendered` is the flag Inertia's client checks to decide between
51
+ * hydrating the markup already on the page and throwing it away to render from
52
+ * scratch. It is set only when the server actually rendered the component — on an
53
+ * empty root it would tell the client to hydrate nothing, which React reports as a
54
+ * mismatch on every page.
55
+ *
56
+ * @param serverRendered - Whether the root contains server-rendered markup.
57
+ */
58
+ export function rootOpen(serverRendered: boolean): string {
59
+ return serverRendered
60
+ ? `<div data-server-rendered="true" id="${APP_ID}">`
61
+ : `<div id="${APP_ID}">`;
62
+ }
63
+
64
+ /** The closing tag of the mount root. */
65
+ export const ROOT_CLOSE = "</div>";
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Splicing server-rendered `<Head>` tags into the template's `<head>`.
3
+ *
4
+ * Inertia's head managers hand back an array of finished HTML strings — a
5
+ * `<title>`, some `<meta>`, whatever the page's `<Head>` declared. Getting them
6
+ * into the document looks like string concatenation and is not, because the
7
+ * template already has a `<head>` with opinions in it.
8
+ *
9
+ * Appending is the obvious move and it is wrong. Every Zerotal template ships a
10
+ * `<title>`, and a document with two titles is a document with the **first** one:
11
+ * the app name, on every page. The tag the page rendered would be present in the
12
+ * markup, correct, and ignored — which is a worse failure than not injecting at
13
+ * all, because it looks like it worked. The same holds for
14
+ * `<meta name="description">` and for the `og:` pair a link preview reads.
15
+ *
16
+ * So a rendered tag *replaces* the template's tag of the same identity, and only
17
+ * tags with no counterpart are appended before `</head>`.
18
+ *
19
+ * @module
20
+ */
21
+
22
+ /** The template's `<title>`, whatever it says. */
23
+ const TITLE_TAG = /<title\b[^>]*>[\s\S]*?<\/title>/i;
24
+
25
+ /**
26
+ * Where injected tags go. The *first* occurrence closes the head — a later one is
27
+ * page content (a template that documents its own markup has the literal string in
28
+ * a code block), and splicing there puts the title in the body.
29
+ */
30
+ const HEAD_CLOSE = "</head>";
31
+
32
+ /**
33
+ * The identity of a `<meta>` tag: its `name` or `property`, lowercased.
34
+ *
35
+ * These are the two attributes that make one meta tag a replacement for another —
36
+ * `name="description"`, `property="og:title"`. A meta with neither (`charset`,
37
+ * `http-equiv`) has no identity to match on and is treated as unkeyed.
38
+ *
39
+ * @param tag - A rendered `<meta …>` tag.
40
+ * @returns `{ attr, value }`, or `null` when the tag is not a keyed meta.
41
+ */
42
+ function metaIdentity(tag: string): { attr: string; value: string } | null {
43
+ const match = /^<meta\b[^>]*?\s(name|property)\s*=\s*["']([^"']*)["']/i.exec(tag);
44
+ if (!match) return null;
45
+ return { attr: match[1]!.toLowerCase(), value: match[2]! };
46
+ }
47
+
48
+ /** Escape a string for literal use inside a RegExp. */
49
+ function escapeRegExp(value: string): string {
50
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
51
+ }
52
+
53
+ /**
54
+ * Inject rendered head tags into an HTML prefix, replacing what they supersede.
55
+ *
56
+ * @param prefix - The HTML up to the injection point — everything containing `<head>`.
57
+ * @param head - Rendered head tags, as Inertia's head manager produces them.
58
+ * @returns The prefix with the tags spliced in. Returned unchanged when `head` is empty.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * injectHead('<head><title>App</title></head>', ['<title>Trip to Kruger</title>']);
63
+ * // → '<head><title>Trip to Kruger</title></head>'
64
+ * ```
65
+ */
66
+ export function injectHead(prefix: string, head: string[]): string {
67
+ if (head.length === 0) return prefix;
68
+
69
+ let html = prefix;
70
+ const appended: string[] = [];
71
+
72
+ for (const tag of head) {
73
+ if (/^<title\b/i.test(tag)) {
74
+ // A title always wins over the template's, and there is only ever one.
75
+ if (TITLE_TAG.test(html)) {
76
+ html = html.replace(TITLE_TAG, tag);
77
+ } else {
78
+ appended.push(tag);
79
+ }
80
+ continue;
81
+ }
82
+
83
+ const identity = metaIdentity(tag);
84
+ if (identity) {
85
+ const existing = new RegExp(
86
+ `<meta\\b[^>]*\\b${identity.attr}\\s*=\\s*["']${escapeRegExp(identity.value)}["'][^>]*>`,
87
+ "i",
88
+ );
89
+ if (existing.test(html)) {
90
+ html = html.replace(existing, tag);
91
+ continue;
92
+ }
93
+ }
94
+
95
+ appended.push(tag);
96
+ }
97
+
98
+ if (appended.length === 0) return html;
99
+
100
+ const close = html.indexOf(HEAD_CLOSE);
101
+ // No `</head>` is a template we cannot reason about — a fragment, or one that
102
+ // relies on the parser to close the head for it. Appending is still better than
103
+ // dropping the tags, and it lands in the same place the parser would put them.
104
+ if (close === -1) return html + appended.join("");
105
+
106
+ return html.slice(0, close) + appended.join("") + html.slice(close);
107
+ }
@@ -4,14 +4,33 @@
4
4
  * Inertia's SSR contract is `{ component, props, url } → { head, body }`. Pages
5
5
  * may be authored as React `.tsx` or Vue `.vue` components, so this module
6
6
  * detects the framework from the page file on disk and renders with the matching
7
- * runtime — React via `react-dom/server`, Vue via `@inertiajs/vue3`'s SSR mode +
8
- * `vue/server-renderer`.
7
+ * runtime — React via `@inertiajs/react` + `react-dom/server`, Vue via
8
+ * `@inertiajs/vue3` + `vue/server-renderer`.
9
9
  *
10
10
  * Every framework runtime is resolved from the *app's* node_modules (not
11
11
  * @zerotal/inertia's), so an app only needs the libraries for the framework it
12
12
  * actually uses. Vue `.vue` files additionally require the `.vue` runtime loader
13
13
  * registered by InertiaProvider (see `registerVueRuntimeLoader`).
14
+ *
15
+ * ## Why React goes through `<App>` rather than the page component
16
+ *
17
+ * The obvious React render is `createElement(PageComponent, props)`, and it was
18
+ * what this module did. It produces correct-looking body HTML and silently drops
19
+ * every `<Head>` tag on the page, because `<Head>` renders nothing — it reports
20
+ * its children to a head manager it reads from context, and nothing had put one
21
+ * there. A page that set a title, a description and an og: card contributed all
22
+ * three to a manager that did not exist, and the server sent the template's
23
+ * `<head>` exactly as written. Nothing failed and nothing logged; the page was
24
+ * perfect in a browser, where React had run, and a link pasted into a chat was a
25
+ * grey rectangle with a domain in it.
26
+ *
27
+ * `@inertiajs/react`'s `<App>` is what installs the head manager, and
28
+ * `onHeadUpdate` is the public prop it reports through. So React renders the same
29
+ * component tree the browser will, and the tags come back.
30
+ *
31
+ * @module
14
32
  */
33
+ import { pageScript, rootOpen, ROOT_CLOSE } from "../pageScript.ts";
15
34
 
16
35
  export type Framework = "vue" | "react";
17
36
 
@@ -26,6 +45,22 @@ export interface SsrResult {
26
45
  body: string;
27
46
  }
28
47
 
48
+ /**
49
+ * A React page rendered far enough to hand to a renderer, but not yet rendered.
50
+ *
51
+ * The head tags are only known *after* the element has been through
52
+ * `react-dom/server`, because `<Head>` reports them during render. Callers render
53
+ * `element`, then read `head()`. Reading it earlier is not an error, it is just
54
+ * empty — which is exactly the failure this shape exists to make impossible to
55
+ * write by accident.
56
+ */
57
+ export interface PreparedReactRender {
58
+ /** The `<App>` element, ready for any `react-dom/server` entry point. */
59
+ element: unknown;
60
+ /** The head tags collected during the render. Empty until `element` has been rendered. */
61
+ head: () => string[];
62
+ }
63
+
29
64
  /**
30
65
  * Resolve a page component's module path and which frontend framework it targets,
31
66
  * preferring a `.vue` SFC when present and falling back to `.tsx`.
@@ -51,6 +86,12 @@ export async function resolvePageModule(
51
86
  * `modPath` is an absolute path to the page module; `framework` selects the
52
87
  * rendering runtime (use {@link resolvePageModule} to derive both).
53
88
  *
89
+ * The `body` is the complete Inertia SSR root — the `<script data-page>` tag and
90
+ * the `<div id="app">` around the rendered component — for both frameworks. That
91
+ * is the shape the Inertia SSR contract specifies and the shape a template drops
92
+ * in whole; React used to return the bare component HTML instead, which is why
93
+ * the two branches had to be spliced differently by every caller.
94
+ *
54
95
  * @param page - The `{ component, props, url }` page to render.
55
96
  * @param modPath - Absolute path to the page component module.
56
97
  * @param framework - Which runtime to render with (`"vue"` or `"react"`).
@@ -98,25 +139,139 @@ async function _renderVue(page: SsrPage, modPath: string): Promise<SsrResult> {
98
139
  return { head: result.head ?? [], body: result.body };
99
140
  }
100
141
 
101
- async function _renderReact(page: SsrPage, modPath: string): Promise<SsrResult> {
142
+ /** Minimal shape of the `@inertiajs/react` exports this module uses. */
143
+ interface InertiaReactModule {
144
+ App: unknown;
145
+ }
146
+
147
+ /** Minimal shape of the `react` exports this module uses. */
148
+ interface ReactModule {
149
+ createElement: (type: unknown, props: unknown) => unknown;
150
+ }
151
+
152
+ /**
153
+ * Build the `<App>` element for a React page and wire a head collector to it.
154
+ *
155
+ * Exported because streaming SSR needs the element rather than a string: it hands
156
+ * it to `renderToReadableStream` and reads the head back once the shell resolves.
157
+ *
158
+ * @param page - The `{ component, props, url }` page to render.
159
+ * @param modPath - Absolute path to the page component module.
160
+ * @returns The element and a `head()` accessor, valid after the element is rendered.
161
+ * @throws {@link Error} When the page module has no default export, or the app does
162
+ * not install `@inertiajs/react`.
163
+ * @internal
164
+ */
165
+ export async function _prepareReactRender(
166
+ page: SsrPage,
167
+ modPath: string,
168
+ ): Promise<PreparedReactRender> {
102
169
  const pageMod = (await import(modPath)) as { default: unknown };
103
- if (typeof pageMod.default !== "function") {
170
+ if (pageMod.default === undefined || pageMod.default === null) {
104
171
  throw new Error(`SSR component "${page.component}" has no default export`);
105
172
  }
106
173
 
107
- // Specifiers via variables so TypeScript does not resolve them: React is an
108
- // optional peer, and a Vue app type-checking this package must not be asked
109
- // for modules it will never install. Both results are cast on the next lines.
174
+ // Specifiers via variables so TypeScript does not resolve them: React and its
175
+ // Inertia adapter are *optional* peers, and a Vue app type-checking this package
176
+ // must not be asked for modules it will never install. Both results are cast.
110
177
  const reactSpecifier = "react";
111
- const reactServerSpecifier = "react-dom/server";
178
+ const inertiaReactSpecifier = "@inertiajs/react";
112
179
 
113
- const [reactMod, serverMod] = await Promise.all([
114
- import(reactSpecifier) as Promise<{
115
- createElement: (type: unknown, props: unknown) => unknown;
116
- }>,
117
- import(reactServerSpecifier) as Promise<{ renderToString: (element: unknown) => string }>,
180
+ const [reactMod, inertiaReact] = await Promise.all([
181
+ import(reactSpecifier) as Promise<ReactModule>,
182
+ _importInertiaReact(inertiaReactSpecifier),
118
183
  ]);
119
184
 
120
- const element = reactMod.createElement(pageMod.default, { ...page.props, url: page.url });
121
- return { head: [], body: serverMod.renderToString(element) };
185
+ return _elementFor(reactMod, inertiaReact, page, pageMod.default);
186
+ }
187
+
188
+ /**
189
+ * Wrap an already-resolved page component in Inertia's `<App>`.
190
+ *
191
+ * Split out so a test can render a component it imported directly, without a pages
192
+ * directory or a module path — see `@zerotal/inertia/testing`.
193
+ *
194
+ * @internal
195
+ */
196
+ function _elementFor(
197
+ reactMod: ReactModule,
198
+ inertiaReact: InertiaReactModule,
199
+ page: SsrPage,
200
+ component: unknown,
201
+ ): PreparedReactRender {
202
+ let head: string[] = [];
203
+
204
+ // `<App>` owns the head manager, the page context and the layout resolution —
205
+ // rendering the page component alone gets the markup and none of the rest.
206
+ // `onHeadUpdate` is called synchronously during render on the server (Inertia's
207
+ // head manager only debounces in a browser), so `head` is populated by the time
208
+ // whichever renderer we were handed to has produced its shell.
209
+ const element = reactMod.createElement(inertiaReact.App, {
210
+ initialPage: page,
211
+ initialComponent: component,
212
+ resolveComponent: () => component,
213
+ onHeadUpdate: (elements: string[]) => {
214
+ head = elements;
215
+ },
216
+ });
217
+
218
+ return { element, head: () => head };
219
+ }
220
+
221
+ /**
222
+ * Build the `<App>` element for a component the caller already has.
223
+ *
224
+ * @param page - The `{ component, props, url }` page to render.
225
+ * @param component - The page component itself.
226
+ * @internal
227
+ */
228
+ export async function _prepareComponentRender(
229
+ page: SsrPage,
230
+ component: unknown,
231
+ ): Promise<PreparedReactRender> {
232
+ const reactSpecifier = "react";
233
+ const inertiaReactSpecifier = "@inertiajs/react";
234
+ const [reactMod, inertiaReact] = await Promise.all([
235
+ import(reactSpecifier) as Promise<ReactModule>,
236
+ _importInertiaReact(inertiaReactSpecifier),
237
+ ]);
238
+ return _elementFor(reactMod, inertiaReact, page, component);
239
+ }
240
+
241
+ /**
242
+ * Import `@inertiajs/react`, turning "not installed" into a sentence that says what
243
+ * to do about it.
244
+ *
245
+ * A bare specifier rather than `Bun.resolveSync(spec, cwd)`, matching how `react`
246
+ * and `react-dom/server` are already loaded here. Node resolution walks up from this
247
+ * module, so a normal flat install finds the app's own copy — and a workspace that
248
+ * keeps the adapter beside the framework instead of at the app root still resolves,
249
+ * which the cwd form does not.
250
+ *
251
+ * The raw failure names a module path and a package, which reads like a bug in the
252
+ * framework rather than a missing dependency in the app — and React SSR did not need
253
+ * this package until `<Head>` started working, so an app upgrading into it meets the
254
+ * error without having changed anything of its own.
255
+ */
256
+ async function _importInertiaReact(specifier: string): Promise<InertiaReactModule> {
257
+ try {
258
+ return (await import(specifier)) as InertiaReactModule;
259
+ } catch (err) {
260
+ throw new Error(
261
+ `Inertia SSR needs "@inertiajs/react" installed. Install it with: bun add @inertiajs/react\n` +
262
+ ` Cause: ${(err as Error).message ?? String(err)}`,
263
+ );
264
+ }
265
+ }
266
+
267
+ async function _renderReact(page: SsrPage, modPath: string): Promise<SsrResult> {
268
+ const { element, head } = await _prepareReactRender(page, modPath);
269
+
270
+ const reactServerSpecifier = "react-dom/server";
271
+ const serverMod = (await import(reactServerSpecifier)) as {
272
+ renderToString: (element: unknown) => string;
273
+ };
274
+
275
+ const html = serverMod.renderToString(element);
276
+ return { head: head(), body: pageScript(page) + rootOpen(true) + html + ROOT_CLOSE };
122
277
  }
package/src/testing.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * `@zerotal/inertia/testing` — render a page component the way a browser will.
3
+ *
4
+ * ## The gap this fills
5
+ *
6
+ * `assertInertia("home")` proves the *server* named a component and handed it props.
7
+ * It proves nothing about the component. A page can throw on its first paint — a
8
+ * destructured prop that is not there, a layout callback reading `page.props` that
9
+ * the callback never receives — and the route still answers `200` with a correct
10
+ * payload, because the throw happens in a browser that the test never opened.
11
+ *
12
+ * An app shipped a blank `/mail` to production with **614 passing tests**. Every one
13
+ * of them asserted a value or a status code. The console said
14
+ * `Cannot read properties of undefined (reading 'search')`, from a layout callback,
15
+ * on a page whose Inertia payload was perfect.
16
+ *
17
+ * {@link renderPage} proves one thing and one thing only: that the component tree
18
+ * can be built without throwing. That is precisely the thing nothing else checks,
19
+ * and it is about forty lines an app should not have to write.
20
+ *
21
+ * ## What it does not do
22
+ *
23
+ * It is not a DOM. Nothing here clicks, and `useEffect` does not run — this is
24
+ * `renderToString`, so it exercises the render pass. For assertions about behaviour
25
+ * after paint, use the browser harness in `@zerotal/testing/browser`.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * import { renderPage } from "@zerotal/inertia/testing";
30
+ * import Home from "../resources/js/pages/home.tsx";
31
+ *
32
+ * test("home renders", async () => {
33
+ * await renderPage(Home, { title: "Hello" });
34
+ * });
35
+ * ```
36
+ *
37
+ * @module
38
+ */
39
+ import { _prepareComponentRender } from "./ssr/renderPage.ts";
40
+
41
+ /** Extras a page render may need beyond its own props. */
42
+ export interface RenderPageOptions {
43
+ /**
44
+ * Shared props the app's `Inertia.share()` would have added — `auth`, `flash`,
45
+ * `errors`, and anything else a layout reads. A page that destructures one of
46
+ * these throws without it, which is a real failure but rarely the one you are
47
+ * testing for, so seed the shape your app actually shares.
48
+ */
49
+ shared?: Record<string, unknown> | undefined;
50
+ /** The URL the page believes it is at. Some layouts branch on it. Default `"/"`. */
51
+ url?: string | undefined;
52
+ /** The component name recorded in the page object. Default `"page"`. */
53
+ component?: string | undefined;
54
+ }
55
+
56
+ /**
57
+ * Render an Inertia page component to HTML, throwing whatever it throws.
58
+ *
59
+ * Renders through `@inertiajs/react`'s own `<App>`, so `usePage()`, `<Head>` and a
60
+ * persistent layout all behave as they do in the browser — a layout attached with
61
+ * `Page.layout` is resolved and rendered too, which is the case worth catching.
62
+ *
63
+ * @param component - The page component, imported directly.
64
+ * @param props - The props the server would send. Merged over `options.shared`.
65
+ * @param options - Shared props, URL and component name.
66
+ * @returns The rendered HTML, for a `toContain` if you want one.
67
+ * @throws Whatever the component throws, unchanged — the point is that it surfaces.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * const html = await renderPage(Profile, { user }, { shared: { auth: { user } } });
72
+ * expect(html).toContain(user.name);
73
+ * ```
74
+ */
75
+ export async function renderPage(
76
+ component: unknown,
77
+ props: Record<string, unknown> = {},
78
+ options: RenderPageOptions = {},
79
+ ): Promise<string> {
80
+ const page = {
81
+ component: options.component ?? "page",
82
+ props: { ...(options.shared ?? {}), ...props },
83
+ url: options.url ?? "/",
84
+ version: "test",
85
+ };
86
+
87
+ const { element } = await _prepareComponentRender(page, component);
88
+
89
+ // Specifier via a variable: `react-dom/server` is an optional peer, and a Vue app
90
+ // type-checking this package must not be asked for a module it will never install.
91
+ const reactServerSpecifier = "react-dom/server";
92
+ const serverMod = (await import(reactServerSpecifier)) as {
93
+ renderToString: (element: unknown) => string;
94
+ };
95
+
96
+ return serverMod.renderToString(element);
97
+ }
package/src/types.ts CHANGED
@@ -49,9 +49,9 @@ export interface PageObject {
49
49
  */
50
50
  export interface InertiaProviderOptions {
51
51
  /** Path to the HTML template. Default: 'resources/app.html' */
52
- htmlTemplate?: string;
52
+ htmlTemplate?: string | undefined;
53
53
  /** Current asset version string. Used for cache-busting (409 responses). */
54
- version?: string;
54
+ version?: string | undefined;
55
55
  /** Public URL prefix for built assets. Default: '/assets' */
56
- assetsUrl?: string;
56
+ assetsUrl?: string | undefined;
57
57
  }