@zerotal/inertia 1.13.3 → 1.13.5

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,31 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.13.5] — 2026-09-01
12
+
13
+ ### Fixed
14
+
15
+ - **`inertia.ssr: true` now actually server-renders.** The flag registered `POST /__ssr`
16
+ and nothing in the request path consulted it, so an app that set it — and read the
17
+ documentation, which said the server renders the component into the template — got
18
+ exactly the empty root it had before.
19
+
20
+ `Inertia.render()` renders the component into the root, injects the page's `<Head>` into
21
+ the served `<head>`, and marks the root `data-server-rendered`. The scaffolded `app.tsx`
22
+ already hydrated on that attribute, so the client half needed no change: an app turns SSR
23
+ on with one config line and nothing else.
24
+
25
+ Server rendering was previously reachable only by rewriting each route to
26
+ `Inertia.stream()`, one call site at a time, which is not what a global switch means. An
27
+ app that did that can go back to `render()`.
28
+
29
+ A component that fails to render falls back to the client-rendered document with a
30
+ warning rather than failing the route: the page still works in a browser, and taking a
31
+ route down because an _optimisation_ failed would make `ssr: true` a liability.
32
+
33
+ `POST /__ssr` is unchanged and stays documented for what it is — the contract for an
34
+ external renderer, not the in-process switch.
35
+
11
36
  ## [1.13.2] — 2026-08-31
12
37
 
13
38
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/inertia",
3
- "version": "1.13.3",
3
+ "version": "1.13.5",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -34,7 +34,7 @@
34
34
  "typecheck": "tsc --noEmit"
35
35
  },
36
36
  "dependencies": {
37
- "@zerotal/core": "1.13.3"
37
+ "@zerotal/core": "1.13.5"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "react": "^18 || ^19",
package/src/inertia.ts CHANGED
@@ -439,6 +439,47 @@ function _writeInertiaJson(ctx: HttpContext, pageObject: unknown): void {
439
439
  });
440
440
  }
441
441
 
442
+ /** Whether `inertia.ssr` is on. Read per request, so a config change needs no rebuild. */
443
+ function _ssrEnabled(): boolean {
444
+ return config.safe<boolean>("inertia.ssr", false) === true;
445
+ }
446
+
447
+ /**
448
+ * The full document with the component rendered into the root.
449
+ *
450
+ * Buffered rather than streamed, because this is `render()`: the caller asked for a
451
+ * page, not for time-to-first-byte. {@link inertiaStream} is the streaming form and
452
+ * stays a per-route choice, since streaming trades TTFB against a shell that arrives
453
+ * in pieces — a decision that belongs to a route rather than to an application.
454
+ *
455
+ * Falls back to the un-rendered document when a component cannot be rendered. A page
456
+ * that fails to server-render still works in the browser, so taking the route down
457
+ * because an *optimisation* failed would make `ssr: true` a liability rather than an
458
+ * improvement. The failure is logged rather than swallowed.
459
+ */
460
+ async function _renderedHtml(component: string, pageObject: PageObject): Promise<string> {
461
+ const [prefix = "", suffix = ""] = _bustAssets(_htmlTemplate).split("<!-- @inertia -->");
462
+
463
+ try {
464
+ const { modPath, framework } = await resolvePageModule(_getPagesDir(), component);
465
+ const { body, head } = await renderInertiaPage(pageObject, modPath, framework);
466
+
467
+ // `body` is already the whole mount root — `renderInertiaPage` returns
468
+ // `pageScript + rootOpen + html + ROOT_CLOSE` for React, and Vue's SSR result
469
+ // carries its own root too. Wrapping it again emits two `<div id="app">` and
470
+ // two `data-page` scripts, and the client hydrates against the wrong one.
471
+ // Composed exactly as `inertiaStream` composes it, so the two cannot drift.
472
+ return injectHead(prefix, head) + body + suffix;
473
+ } catch (error) {
474
+ console.warn(
475
+ `[Inertia] SSR render failed for "${component}", serving the client-rendered ` +
476
+ `document instead: ${(error as Error).message}`,
477
+ );
478
+ return `${prefix}${rootOpen(false)}${ROOT_CLOSE}
479
+ ${pageScript(pageObject)}${suffix}`;
480
+ }
481
+ }
482
+
442
483
  async function _inertia(component: string, props: Record<string, unknown>): Promise<void> {
443
484
  const ctx = RequestContext.get();
444
485
  const isInertiaRequest = ctx.request.headers.get("X-Inertia") === "true";
@@ -454,16 +495,25 @@ async function _inertia(component: string, props: Record<string, unknown>): Prom
454
495
  throw new InertiaTemplateNotLoadedError();
455
496
  }
456
497
 
457
- // Inject pageObject into the HTML template. The root is empty this path does
458
- // not server-render the component, so it is deliberately *not* marked
459
- // `data-server-rendered`: that flag tells the client to hydrate, and hydrating an
460
- // empty div is a mismatch on every page. See `inertiaStream()` for the rendered
461
- // form, and the "What a crawler sees" section of the Inertia docs for what this
462
- // response contains.
463
- const html = _bustAssets(_htmlTemplate).replace(
464
- "<!-- @inertia -->",
465
- `${rootOpen(false)}${ROOT_CLOSE}\n ${pageScript(pageObject)}`,
466
- );
498
+ // `inertia.ssr` renders the component here, on the first load, for every page.
499
+ //
500
+ // That is what the option is named for and what every Inertia adapter does with
501
+ // it and until 1.13.4 it did not: the flag registered `POST /__ssr` and nothing
502
+ // in the request path consulted it, so an app that set `ssr: true` and read the
503
+ // documentation got exactly the empty root it had before. Server rendering was
504
+ // reachable only by rewriting each route to `Inertia.stream()`, one call site at
505
+ // a time, which is not what a global switch means.
506
+ const html = _ssrEnabled()
507
+ ? await _renderedHtml(component, pageObject)
508
+ : _bustAssets(_htmlTemplate).replace(
509
+ "<!-- @inertia -->",
510
+ // The root is empty, so it is deliberately *not* marked
511
+ // `data-server-rendered`: that flag tells the client to hydrate, and
512
+ // hydrating an empty div is a mismatch on every page. See the "What a
513
+ // crawler sees" section of the Inertia docs for what this contains.
514
+ `${rootOpen(false)}${ROOT_CLOSE}
515
+ ${pageScript(pageObject)}`,
516
+ );
467
517
 
468
518
  ctx.response = new Response(html, {
469
519
  headers: {