@zerotal/inertia 1.8.1 → 1.10.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,93 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.10.0] — 2026-08-30
12
+
13
+ ### Fixed
14
+
15
+ - **React SSR emits the page's `<Head>` tags.** The React branch rendered the page
16
+ component directly — `createElement(Page, props)` — which produces correct body
17
+ markup and drops every `<Head>` on the page. `<Head>` renders nothing; it reports
18
+ its children to a head manager it reads from context, and rendering the component
19
+ alone puts none there. So a page that set a title, a description and an og: card
20
+ contributed all three to nothing, and the server sent the template's `<head>`
21
+ verbatim. Nothing failed and nothing logged — the page was perfect in a browser,
22
+ where React had run — and a link pasted into a chat was a grey rectangle with a
23
+ domain in it.
24
+
25
+ Both server-rendered paths (`inertiaStream()` and `POST /__ssr`) now render through
26
+ `@inertiajs/react`'s `<App>`, which installs the head manager, and splice what comes
27
+ back into the template's `<head>`. **React apps using SSR must have
28
+ `@inertiajs/react` installed** — the same adapter the browser entry point already
29
+ uses; a missing one is now a named error rather than a silent omission.
30
+
31
+ - **An injected head tag replaces the template's, rather than being appended after
32
+ it.** This applies to Vue as well, where head injection did work: the templates all
33
+ ship a `<title>`, and a document with two titles is a document with the _first_
34
+ one. The page's tag was present, correct and ignored. A rendered `<title>` now
35
+ replaces the template's, and a `<meta>` replaces the one with the same `name` or
36
+ `property`; anything with no counterpart is appended before `</head>`.
37
+
38
+ - **The React SSR root is marked `data-server-rendered`, and the page script comes
39
+ first.** The streaming branch emitted an unmarked `<div id="app">`, so the client
40
+ discarded the server's markup and rendered the page a second time — paying for SSR
41
+ and then throwing it away. `POST /__ssr` also returns the same body shape as the Vue
42
+ branch now (the whole Inertia root, ready to drop into a template) instead of the
43
+ bare component HTML.
44
+
45
+ ### Documented
46
+
47
+ - **["What a crawler sees"](/docs/inertia/ssr#what-a-crawler-sees)** — `inertia()` does
48
+ not server-render the component at all, which is the normal Inertia arrangement and
49
+ worth saying out loud: the served document is a `<title>` and a JSON blob. The page
50
+ names which readers run JavaScript (browsers, search engines on a second pass) and
51
+ which do not (every link-preview scraper, `curl`, most reader tools), and the three
52
+ ways to give the second group something to read.
53
+
54
+ ## [1.9.0] — 2026-08-29
55
+
56
+ ### Fixed
57
+
58
+ - **A rebuilt bundle no longer 404s on a chunk the browser asks for.** `resources/js/app.tsx`
59
+ builds to `/assets/app.js` under that name every time, while `splitting: true` names each chunk
60
+ after its content. A rebuild therefore rewrites `app.js` to import `chunk-NEW.js` and prunes
61
+ `chunk-OLD.js` — and a browser holding a cached `app.js` asks for the pruned one and gets
62
+
63
+ GET /assets/chunk-hrnspqda.js status=404
64
+
65
+ from a page that renders and a server that is healthy. Nothing in that line leads back to the
66
+ template.
67
+
68
+ The template hardcodes `/assets/app.js` rather than calling `asset()`, so the version token the
69
+ rest of the framework appends never reached it, and cache-busting had only ever been
70
+ implemented for `--dev-worker`. It now applies everywhere: the file's mtime in dev, where a
71
+ rebuild happens without a restart, and the boot-derived asset version otherwise, memoised
72
+ because a deploy restarts the process. An unchanged asset keeps a stable URL and stays cached,
73
+ which is why the token is derived rather than random.
74
+
75
+ ### Documented
76
+
77
+ - **Every promised export is documented.** The `docs-coverage` gate reads `maturity: stable` as a
78
+ promise about a package's exports, and measures how much of that promise is written down. It
79
+ was 798 gaps across the suite; it is now zero. This package's share is covered on its own
80
+ pages — types named, options shapes described, and the decisions behind them recorded where
81
+ somebody looking for them will find them.
82
+
83
+ ### Added
84
+
85
+ - **A warning when a model crosses into page props having declared no boundary.** Page props
86
+ are page source: everything handed to `inertia()` is serialised into the document, and
87
+ `return inertia("Trips/Show", { trip })` is what a newcomer writes on their first afternoon
88
+ and ships the whole row — the internal cost, the margin, the note about the customer, on
89
+ the customer's own screen. Nothing fails and the page looks right, which makes it the one
90
+ mistake here that never announces itself.
91
+
92
+ The ORM's `hidden` / `visible` lists were already honoured, since they are applied by
93
+ `toJSON()` and that is what serialises a prop — nothing said so. In development, passing a
94
+ model that declares neither list now names the model and the number of fields it is about
95
+ to publish. It fires once per model class and goes quiet as soon as either list exists, so
96
+ the normal case of passing models stays quiet.
97
+
11
98
  ## [1.8.0] — 2026-08-24
12
99
 
13
100
  ### Fixed
package/api-surface.md CHANGED
@@ -256,12 +256,6 @@ interface InertiaDevtoolsConfig = {
256
256
 
257
257
  interface InertiaPageRegistry = {}
258
258
 
259
- interface InertiaProviderOptions = {
260
- assetsUrl?: string
261
- htmlTemplate?: string
262
- version?: string
263
- }
264
-
265
259
  interface MergeConfig = {
266
260
  appendPaths: string[]
267
261
  deep: boolean
@@ -303,18 +297,6 @@ interface PaginatorLike = {
303
297
  total?: number
304
298
  }
305
299
 
306
- interface ResolvedPage = {
307
- deepMergeProps?: string[]
308
- deferredProps?: Record<string, string[]>
309
- matchPropsOn?: string[]
310
- mergeProps?: string[]
311
- onceProps?: Record<string, { prop: string; expiresAt: number | null;}>
312
- prependProps?: string[]
313
- props: Record<string, unknown>
314
- rescuedProps?: string[]
315
- scrollProps?: Record<string, ScrollConfig>
316
- }
317
-
318
300
  interface ScrollConfig = {
319
301
  currentPage: number | null
320
302
  nextPage: number | null
@@ -328,8 +310,6 @@ type PageName = never
328
310
 
329
311
  type PageTarget = string
330
312
 
331
- type PropFactory = () => T | Promise<T>
332
-
333
313
  type PropInput = T | (() => T | Promise<T>) | AlwaysProp<T> | MergeProp<T> | (T extends PaginatorLike ? InfiniteScrollProp : never) | (undefined extends T ? OptionalProp<T> | DeferProp<T> : never)
334
314
 
335
315
  type PropsOf = PageComponent<N> extends (props: infer Props, ...rest: any[]) => any ? Props : PageComponent<N> extends abstract new (props: infer Props, ...rest: any[]) => any ? Props : Record<string, unknown>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/inertia",
3
- "version": "1.8.1",
3
+ "version": "1.10.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -33,7 +33,7 @@
33
33
  "typecheck": "tsc --noEmit"
34
34
  },
35
35
  "dependencies": {
36
- "@zerotal/core": "1.8.1"
36
+ "@zerotal/core": "1.10.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "react": "^18 || ^19",
@@ -60,6 +60,7 @@
60
60
  }
61
61
  },
62
62
  "devDependencies": {
63
+ "@inertiajs/react": "^3.7.0",
63
64
  "react": "^19.2.7",
64
65
  "react-dom": "^19.2.7",
65
66
  "typescript": "^5.8.0"
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
@@ -1,14 +1,18 @@
1
1
  import { config, RequestContext } from "@zerotal/core";
2
+ import { assetVersion as coreAssetVersion } from "@zerotal/core/assets";
2
3
  import { statSync } from "node:fs";
3
4
  import { InertiaTemplateNotLoadedError, InvalidComponentError } from "./errors.ts";
4
5
  import { DEFAULT_PAGES_DIR } from "./config.ts";
5
6
  import { sharedProps } from "./SharedProps.ts";
6
7
  import { assetVersion } from "./version.ts";
7
8
  import { resolveProps } from "./props/resolveProps.ts";
9
+ import { checkPropBoundary } from "./props/propBoundary.ts";
8
10
  import { readHistoryFlags } from "./historyState.ts";
9
11
  import { allSharedKeys } from "./share.ts";
10
12
  import { recordPage } from "./devtools/recorder.ts";
11
- 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";
12
16
  import type { PageObject } from "./types.ts";
13
17
  import type { PageTarget, RenderArgs } from "./pages.ts";
14
18
 
@@ -81,6 +85,12 @@ export async function buildPageObject(
81
85
  // a no-op unless the DevTools recorder opened a recording for this request.
82
86
  recordPage(component, merged, resolved.props, resolved.rescuedProps);
83
87
 
88
+ // Everything below this line is page source. Last chance to say so — and the
89
+ // only place that sees the props as they will actually be serialised, after
90
+ // partial reloads and lazy wrappers have decided what is really going out.
91
+ // Development only; a no-op on a served request.
92
+ checkPropBoundary(resolved.props);
93
+
84
94
  const history = readHistoryFlags();
85
95
  if (history.encryptHistory) page.encryptHistory = true;
86
96
  if (history.clearHistory) page.clearHistory = true;
@@ -103,6 +113,9 @@ let _pagesDir = "";
103
113
  */
104
114
  export function _setHtmlTemplate(html: string): void {
105
115
  _htmlTemplate = html;
116
+ // The busted copy was derived from the previous template. Keeping it would
117
+ // serve the old markup for as long as the asset token happened to match.
118
+ _resetBustedTemplate();
106
119
  }
107
120
 
108
121
  /**
@@ -136,31 +149,70 @@ export function _getPagesDir(): string {
136
149
  return _pagesDir || `${process.cwd()}/${config.safe("inertia.pagesDir", DEFAULT_PAGES_DIR)}`;
137
150
  }
138
151
 
152
+ /** Local `href="/…"` / `src="/…"` JS and CSS URLs that carry no query already. */
153
+ const _LOCAL_ASSET_URL = /((?:href|src)=")(\/[^"?]+\.(?:js|css))(")/g;
154
+
139
155
  /**
140
- * In dev (`--dev-worker`) append a cache-busting `?v=<mtime>` to local JS/CSS
141
- * asset URLs in the served HTML, so the browser fetches a freshly-rebuilt
142
- * bundle instead of a stale cached copy.
156
+ * Append a cache-busting `?v=…` to the template's local JS/CSS URLs.
157
+ *
158
+ * **The entry point is not content-hashed and the chunks are**, which is the
159
+ * whole reason this exists. `app.tsx` builds to `/assets/app.js` under that name
160
+ * every time, while `splitting: true` names each chunk after its content — so a
161
+ * rebuild rewrites `app.js` to import `chunk-NEW.js` and prunes `chunk-OLD.js`.
162
+ * A browser holding a cached `app.js` then asks for a chunk that is no longer on
163
+ * disk and gets a 404, from a page that renders fine and a server that is
164
+ * perfectly healthy. The stack says `GET /assets/chunk-….js 404` and names
165
+ * nothing that would lead you here.
166
+ *
167
+ * The template hardcodes `/assets/app.js` rather than calling `asset()`, so the
168
+ * version token the rest of the framework appends never reached it. Two token
169
+ * sources, because the two runtimes invalidate at different moments:
143
170
  *
144
- * The token is the asset file's modification time: unchanged assets keep a
145
- * stable URL (and stay cached), while a rebuild changes the URL and forces a
146
- * re-fetch. No-op in production, where assets are served as-is.
171
+ * - **Dev (`--dev-worker`)**: the file's mtime, read per request. A rebuild
172
+ * happens without a restart, so a token fixed at boot would be stale exactly
173
+ * when it matters.
174
+ * - **Everywhere else**: the boot-derived asset version, memoised. A deploy
175
+ * restarts the process — the pipeline says so in as many words — so the token
176
+ * is computed once and reused, rather than stat-ing files on every render.
177
+ *
178
+ * Unchanged assets keep a stable URL and stay cached; that is the point of a
179
+ * derived token rather than a random one.
147
180
  *
148
181
  * @internal
149
182
  */
150
- function _devBustAssets(html: string): string {
151
- if (!process.argv.includes("--dev-worker")) return html;
152
- const root = `${process.cwd()}/public`;
153
- return html.replace(
154
- /((?:href|src)=")(\/[^"?]+\.(?:js|css))(")/g,
155
- (match, pre: string, url: string, post: string) => {
183
+ export function _bustAssets(html: string): string {
184
+ if (process.argv.includes("--dev-worker")) {
185
+ const root = `${process.cwd()}/public`;
186
+ return html.replace(_LOCAL_ASSET_URL, (match, pre: string, url: string, post: string) => {
156
187
  try {
157
188
  const mtime = Math.floor(statSync(`${root}${url}`).mtimeMs);
158
189
  return `${pre}${url}?v=${mtime}${post}`;
159
190
  } catch {
160
191
  return match; // asset not found under public/ — leave the URL untouched
161
192
  }
162
- },
163
- );
193
+ });
194
+ }
195
+
196
+ const token = coreAssetVersion();
197
+ // No token means nothing derived one — an app serving no built assets, or a
198
+ // boot order that has not reached the conventions phase. Leave the URLs alone
199
+ // rather than stamping `?v=` and inventing a second URL for the same file.
200
+ if (!token) return html;
201
+ if (_bustedFor === token) return _bustedHtml;
202
+
203
+ _bustedHtml = html.replace(_LOCAL_ASSET_URL, `$1$2?v=${token}$3`);
204
+ _bustedFor = token;
205
+ return _bustedHtml;
206
+ }
207
+
208
+ /** Memoised output of {@link _bustAssets}, keyed on the token it was built with. */
209
+ let _bustedHtml = "";
210
+ let _bustedFor = "";
211
+
212
+ /** Drop the memoised template. Tests, and any caller that replaces the template. @internal */
213
+ export function _resetBustedTemplate(): void {
214
+ _bustedHtml = "";
215
+ _bustedFor = "";
164
216
  }
165
217
 
166
218
  /**
@@ -215,7 +267,7 @@ async function _inertiaStream(component: string, props: Record<string, unknown>)
215
267
 
216
268
  const pageObject = await buildPageObject(component, props);
217
269
 
218
- const [prefix = "", suffix = ""] = _devBustAssets(_htmlTemplate).split("<!-- @inertia -->");
270
+ const [prefix = "", suffix = ""] = _bustAssets(_htmlTemplate).split("<!-- @inertia -->");
219
271
 
220
272
  const { modPath, framework } = await resolvePageModule(_getPagesDir(), component);
221
273
  const encoder = new TextEncoder();
@@ -224,38 +276,32 @@ async function _inertiaStream(component: string, props: Record<string, unknown>)
224
276
  };
225
277
 
226
278
  if (framework === "react") {
227
- // React's stream is just the component's inner HTML, so Zerotal wraps it in
228
- // the app root and serialises the pageObject into the data-page script.
229
- const safeJson = JSON.stringify(pageObject)
230
- .replace(/</g, "\\u003c")
231
- .replace(/>/g, "\\u003e")
232
- .replace(/&/g, "\\u0026")
233
- .replace(/\//g, "\\/");
234
- const openTag = `<div id="app">`;
235
- const closeBlock = `</div>\n <script type="application/json" data-page="app">${safeJson}</script>`;
236
-
237
- // Specifiers via variables, not literals. React is an *optional* peer — a Vue
238
- // app never installs it but a literal `import("react")` is still resolved by
239
- // TypeScript, so type-checking a Vue project failed on modules it will never
240
- // have. Both results are cast below, so nothing is lost by hiding the
241
- // specifier from the resolver; this branch only runs when the app is React.
242
- 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.
243
294
  const reactServerSpecifier = "react-dom/server";
295
+ const serverMod = (await import(reactServerSpecifier)) as {
296
+ renderToReadableStream(el: unknown): Promise<ReadableStream<Uint8Array>>;
297
+ };
244
298
 
245
- const [reactMod, serverMod, pageMod] = await Promise.all([
246
- import(reactSpecifier) as Promise<{ createElement(type: unknown, props: unknown): unknown }>,
247
- import(reactServerSpecifier) as Promise<{
248
- renderToReadableStream(el: unknown): Promise<ReadableStream<Uint8Array>>;
249
- }>,
250
- import(modPath) as Promise<{ default: unknown }>,
251
- ]);
252
-
253
- const element = reactMod.createElement(pageMod.default, pageObject.props);
254
299
  const reactStream = await serverMod.renderToReadableStream(element);
300
+ const openBlock = injectHead(prefix, head()) + pageScript(pageObject) + rootOpen(true);
255
301
 
256
302
  const readable = new ReadableStream<Uint8Array>({
257
303
  async start(controller) {
258
- controller.enqueue(encoder.encode(prefix + openTag));
304
+ controller.enqueue(encoder.encode(openBlock));
259
305
  const reader = reactStream.getReader();
260
306
  try {
261
307
  while (true) {
@@ -266,7 +312,7 @@ async function _inertiaStream(component: string, props: Record<string, unknown>)
266
312
  } finally {
267
313
  reader.releaseLock();
268
314
  }
269
- controller.enqueue(encoder.encode(closeBlock + suffix));
315
+ controller.enqueue(encoder.encode(ROOT_CLOSE + suffix));
270
316
  controller.close();
271
317
  },
272
318
  });
@@ -275,18 +321,16 @@ async function _inertiaStream(component: string, props: Record<string, unknown>)
275
321
  return;
276
322
  }
277
323
 
278
- // Vue: @inertiajs/vue3's SSR mode already emits the full
279
- // `<div id="app" data-page="…">…</div>` root (the complete pageObject is
280
- // serialised into data-page), so inject it directly in place of the
281
- // placeholder no extra app-div wrapper or data-page script. Any <Head>
282
- // 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).
283
329
  const { body, head } = await renderInertiaPage(pageObject, modPath, framework);
284
- const prefixWithHead =
285
- head.length > 0 ? prefix.replace("</head>", `${head.join("")}</head>`) : prefix;
286
330
 
287
331
  const readable = new ReadableStream<Uint8Array>({
288
332
  start(controller) {
289
- controller.enqueue(encoder.encode(prefixWithHead + body + suffix));
333
+ controller.enqueue(encoder.encode(injectHead(prefix, head) + body + suffix));
290
334
  controller.close();
291
335
  },
292
336
  });
@@ -385,19 +429,15 @@ async function _inertia(component: string, props: Record<string, unknown>): Prom
385
429
  throw new InertiaTemplateNotLoadedError();
386
430
  }
387
431
 
388
- // Inject pageObject into the HTML template.
389
- // Escape characters that would break HTML parsing inside a script tag.
390
- // </script> <\/script>, < <, > >
391
- const safeJson = JSON.stringify(pageObject)
392
- .replace(/</g, "\\u003c")
393
- .replace(/>/g, "\\u003e")
394
- .replace(/&/g, "\\u0026")
395
- .replace(/\//g, "\\/");
396
-
397
- const html = _devBustAssets(_htmlTemplate).replace(
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.
438
+ const html = _bustAssets(_htmlTemplate).replace(
398
439
  "<!-- @inertia -->",
399
- `<div id="app"></div>\n ` +
400
- `<script type="application/json" data-page="app">${safeJson}</script>`,
440
+ `${rootOpen(false)}${ROOT_CLOSE}\n ${pageScript(pageObject)}`,
401
441
  );
402
442
 
403
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>";
@@ -14,6 +14,8 @@
14
14
  * Generic so a wrapper can carry what it will resolve to — that is what lets
15
15
  * `Inertia.render` check `defer(() => stats())` against the `stats` prop the
16
16
  * page component declares, instead of checking that *something* was passed.
17
+ *
18
+ * @internal
17
19
  */
18
20
  export type PropFactory<T = unknown> = () => T | Promise<T>;
19
21
 
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The boundary between a model and a page prop.
3
+ *
4
+ * Inertia page props are page source. Everything handed to `inertia()` is
5
+ * serialised into the HTML document — or returned as JSON on an XHR visit — and
6
+ * anybody who views source reads all of it. That is not a leak in itself; it is
7
+ * how the protocol works. It becomes a leak because of what the obvious code does:
8
+ *
9
+ * ```ts
10
+ * return inertia("Trips/Show", { trip }); // the first thing anyone writes
11
+ * ```
12
+ *
13
+ * `trip` is a model, and a model serialises the row. Every column. The internal
14
+ * cost, the margin, the note somebody left about the customer — all of it, in the
15
+ * page, on the customer's own screen. Nothing fails, nothing logs, and the page
16
+ * looks right.
17
+ *
18
+ * The ORM already has the answer: `static hidden` and `static visible` are honoured
19
+ * by `toJSON()`, which is what serialises a prop. Declaring the dangerous columns
20
+ * once at the model is strictly better than remembering a projection at every call
21
+ * site. What was missing is anything that says so at the moment it matters.
22
+ *
23
+ * So this warns — in development only, once per model class — when a model reaches
24
+ * page props having declared neither list. Not "you passed a model", which is
25
+ * normal and fine, but "you passed a model that has never said which of its columns
26
+ * are safe to publish". A model with either list declared is silent forever after.
27
+ *
28
+ * @module
29
+ */
30
+ import { deployEnv, isDevSurfaceAllowed } from "@zerotal/core";
31
+
32
+ /** A value that serialises like an ORM model. */
33
+ interface ModelLike {
34
+ toJSON(): unknown;
35
+ constructor: { name: string; hidden?: unknown; visible?: unknown };
36
+ }
37
+
38
+ /**
39
+ * Model classes already reported. Keyed by the class itself, so two models with the
40
+ * same name are two findings and a hot path never re-formats a warning nobody
41
+ * needs to read twice.
42
+ */
43
+ const _warned = new WeakSet<object>();
44
+
45
+ /** How deep to look for models inside props. */
46
+ const MAX_DEPTH = 4;
47
+
48
+ /** How many values to look at per level, so a large collection cannot cost the request. */
49
+ const MAX_BREADTH = 50;
50
+
51
+ /**
52
+ * How many fields a value publishes when serialised, or `null` when it is not the
53
+ * kind of thing this check is about.
54
+ *
55
+ * Structural, not `instanceof BaseModel`: `@zerotal/inertia` must not depend on
56
+ * `@zerotal/orm`, and an app can serve Inertia pages with no ORM installed at all.
57
+ * Three conditions together are specific enough — a class instance (not a plain
58
+ * object, which is already a projection), carrying its own `toJSON`, whose
59
+ * `toJSON` returns an object with fields in it.
60
+ *
61
+ * That last one is doing real work. `Date` is a class instance with a `toJSON`,
62
+ * and so are `URL` and the Temporal types; every one of them serialises to a
63
+ * string, and a string has no columns to leak. Requiring an object result is what
64
+ * separates "a row went into the page" from "a timestamp did".
65
+ */
66
+ function serialisedFieldCount(value: unknown): number | null {
67
+ if (value === null || typeof value !== "object") return null;
68
+ if (Array.isArray(value)) return null;
69
+ const prototype = Object.getPrototypeOf(value) as object | null;
70
+ if (prototype === null || prototype === Object.prototype) return null;
71
+ if (typeof (value as { toJSON?: unknown }).toJSON !== "function") return null;
72
+ let json: unknown;
73
+ try {
74
+ json = (value as ModelLike).toJSON();
75
+ } catch {
76
+ // A model whose serialisation throws — an unloaded relation, most likely —
77
+ // has a problem this check is not the right place to report.
78
+ return null;
79
+ }
80
+ if (json === null || typeof json !== "object" || Array.isArray(json)) return null;
81
+ const fields = Object.keys(json).length;
82
+ return fields > 0 ? fields : null;
83
+ }
84
+
85
+ /** Whether a model's class has said anything at all about what is safe to publish. */
86
+ function declaresBoundary(model: ModelLike): boolean {
87
+ const { hidden, visible } = model.constructor;
88
+ return (
89
+ (Array.isArray(hidden) && hidden.length > 0) || (Array.isArray(visible) && visible.length > 0)
90
+ );
91
+ }
92
+
93
+ /**
94
+ * The warning text. Names the model, says how much of it is about to be published,
95
+ * and gives the one-line fix rather than a principle.
96
+ */
97
+ export function propBoundaryWarning(name: string, columns: number, propKey: string): string {
98
+ return (
99
+ `[inertia] \`${name}\` was passed as the \`${propKey}\` prop and declares neither ` +
100
+ `\`hidden\` nor \`visible\`, so all ${columns} of its serialised fields are written into ` +
101
+ `page source — readable by anyone who views source on this page.\n` +
102
+ ` If that is intended, say so once and this goes quiet:\n` +
103
+ ` static hidden: Columns<${name}>[] = ["cost_cents", "internal_notes"];\n` +
104
+ ` static visible: Columns<${name}>[] = ["id", "title"]; // or an allow-list\n` +
105
+ ` Both are honoured by toJSON(), which is what serialises this prop.`
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Warn about models in page props that have never declared a boundary.
111
+ *
112
+ * Development only, and a no-op everywhere else — it walks the resolved prop tree,
113
+ * which is not work a served request should be doing.
114
+ *
115
+ * @param props - The resolved props, as they will be serialised.
116
+ * @param report - Where to write findings. Injectable for tests.
117
+ */
118
+ export function checkPropBoundary(
119
+ props: Record<string, unknown>,
120
+ report: (message: string) => void = (m) => console.warn(m),
121
+ ): void {
122
+ if (!isDevSurfaceAllowed(deployEnv())) return;
123
+
124
+ const visit = (value: unknown, propKey: string, depth: number, seen: WeakSet<object>): void => {
125
+ if (depth > MAX_DEPTH || value === null || typeof value !== "object") return;
126
+ // Props can hold the same model twice, and a loaded relation can point back at
127
+ // its parent. Either would otherwise be walked forever.
128
+ if (seen.has(value)) return;
129
+ seen.add(value);
130
+
131
+ if (Array.isArray(value)) {
132
+ for (const item of value.slice(0, MAX_BREADTH)) visit(item, propKey, depth + 1, seen);
133
+ return;
134
+ }
135
+
136
+ const fields = serialisedFieldCount(value);
137
+ if (fields !== null) {
138
+ const model = value as ModelLike;
139
+ if (!declaresBoundary(model) && !_warned.has(model.constructor)) {
140
+ _warned.add(model.constructor);
141
+ report(propBoundaryWarning(model.constructor.name, fields, propKey));
142
+ }
143
+ return;
144
+ }
145
+
146
+ // A plain object — a paginator, a `{ data: [...] }` envelope, a hand-built
147
+ // projection that happens to carry a model on one key.
148
+ for (const nested of Object.values(value).slice(0, MAX_BREADTH)) {
149
+ visit(nested, propKey, depth + 1, seen);
150
+ }
151
+ };
152
+
153
+ for (const [key, value] of Object.entries(props)) {
154
+ visit(value, key, 0, new WeakSet<object>());
155
+ }
156
+ }
157
+
158
+ /** Forget every class already reported. @internal For tests. */
159
+ export function _resetPropBoundaryWarnings(classes: object[]): void {
160
+ for (const cls of classes) _warned.delete(cls);
161
+ }
@@ -7,7 +7,11 @@ import {
7
7
  type ScrollConfig,
8
8
  } from "./PropTypes.ts";
9
9
 
10
- /** The resolved props plus the page-object metadata the client needs to merge/defer correctly. */
10
+ /**
11
+ * The resolved props plus the page-object metadata the client needs to merge/defer correctly.
12
+ *
13
+ * @internal
14
+ */
11
15
  export interface ResolvedPage {
12
16
  props: Record<string, unknown>;
13
17
  deferredProps?: Record<string, string[]>;
@@ -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,102 @@ 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
+ let head: string[] = [];
186
+
187
+ // `<App>` owns the head manager, the page context and the layout resolution —
188
+ // rendering the page component alone gets the markup and none of the rest.
189
+ // `onHeadUpdate` is called synchronously during render on the server (Inertia's
190
+ // head manager only debounces in a browser), so `head` is populated by the time
191
+ // whichever renderer we were handed to has produced its shell.
192
+ const element = reactMod.createElement(inertiaReact.App, {
193
+ initialPage: page,
194
+ initialComponent: pageMod.default,
195
+ resolveComponent: () => pageMod.default,
196
+ onHeadUpdate: (elements: string[]) => {
197
+ head = elements;
198
+ },
199
+ });
200
+
201
+ return { element, head: () => head };
202
+ }
203
+
204
+ /**
205
+ * Import `@inertiajs/react`, turning "not installed" into a sentence that says what
206
+ * to do about it.
207
+ *
208
+ * A bare specifier rather than `Bun.resolveSync(spec, cwd)`, matching how `react`
209
+ * and `react-dom/server` are already loaded here. Node resolution walks up from this
210
+ * module, so a normal flat install finds the app's own copy — and a workspace that
211
+ * keeps the adapter beside the framework instead of at the app root still resolves,
212
+ * which the cwd form does not.
213
+ *
214
+ * The raw failure names a module path and a package, which reads like a bug in the
215
+ * framework rather than a missing dependency in the app — and React SSR did not need
216
+ * this package until `<Head>` started working, so an app upgrading into it meets the
217
+ * error without having changed anything of its own.
218
+ */
219
+ async function _importInertiaReact(specifier: string): Promise<InertiaReactModule> {
220
+ try {
221
+ return (await import(specifier)) as InertiaReactModule;
222
+ } catch (err) {
223
+ throw new Error(
224
+ `Inertia SSR needs "@inertiajs/react" installed. Install it with: bun add @inertiajs/react\n` +
225
+ ` Cause: ${(err as Error).message ?? String(err)}`,
226
+ );
227
+ }
228
+ }
229
+
230
+ async function _renderReact(page: SsrPage, modPath: string): Promise<SsrResult> {
231
+ const { element, head } = await _prepareReactRender(page, modPath);
232
+
233
+ const reactServerSpecifier = "react-dom/server";
234
+ const serverMod = (await import(reactServerSpecifier)) as {
235
+ renderToString: (element: unknown) => string;
236
+ };
237
+
238
+ const html = serverMod.renderToString(element);
239
+ return { head: head(), body: pageScript(page) + rootOpen(true) + html + ROOT_CLOSE };
122
240
  }
package/src/types.ts CHANGED
@@ -42,12 +42,16 @@ export interface PageObject {
42
42
  sharedProps?: string[];
43
43
  }
44
44
 
45
- /** Options passed to `InertiaProvider` to configure the adapter at boot. */
45
+ /**
46
+ * Options passed to `InertiaProvider` to configure the adapter at boot.
47
+ *
48
+ * @internal
49
+ */
46
50
  export interface InertiaProviderOptions {
47
51
  /** Path to the HTML template. Default: 'resources/app.html' */
48
- htmlTemplate?: string;
52
+ htmlTemplate?: string | undefined;
49
53
  /** Current asset version string. Used for cache-busting (409 responses). */
50
- version?: string;
54
+ version?: string | undefined;
51
55
  /** Public URL prefix for built assets. Default: '/assets' */
52
- assetsUrl?: string;
56
+ assetsUrl?: string | undefined;
53
57
  }