@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 +87 -0
- package/api-surface.md +0 -20
- package/package.json +3 -2
- package/src/global.d.ts +2 -0
- package/src/inertia.ts +103 -63
- package/src/pageScript.ts +65 -0
- package/src/props/PropTypes.ts +2 -0
- package/src/props/propBoundary.ts +161 -0
- package/src/props/resolveProps.ts +5 -1
- package/src/ssr/head.ts +107 -0
- package/src/ssr/renderPage.ts +133 -15
- package/src/types.ts +8 -4
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.
|
|
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.
|
|
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
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
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 = ""] =
|
|
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
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
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(
|
|
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(
|
|
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"
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
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(
|
|
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
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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>";
|
package/src/props/PropTypes.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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[]>;
|
package/src/ssr/head.ts
ADDED
|
@@ -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
|
+
}
|
package/src/ssr/renderPage.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
108
|
-
// optional
|
|
109
|
-
// for modules it will never install. Both results are cast
|
|
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
|
|
178
|
+
const inertiaReactSpecifier = "@inertiajs/react";
|
|
112
179
|
|
|
113
|
-
const [reactMod,
|
|
114
|
-
import(reactSpecifier) as Promise<
|
|
115
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
/**
|
|
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
|
}
|