@zerotal/inertia 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/api-surface.md +10 -0
- package/package.json +4 -3
- package/src/commands/InertiaBuildCommand.ts +14 -0
- package/src/ssr/renderPage.ts +39 -2
- package/src/testing.ts +97 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,41 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.11.0] — 2026-08-31
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`@zerotal/inertia/testing` — `renderPage()`.** `assertInertia("home")` proves the
|
|
16
|
+
_server_ named a component and handed it props. It proves nothing about the
|
|
17
|
+
component, and a page can throw on its first paint while every such test passes: the
|
|
18
|
+
route answers `200`, the payload is correct, and the failure happens in a browser
|
|
19
|
+
the suite never opened. An app shipped a blank page to production with **614 passing
|
|
20
|
+
tests** exactly that way — a layout callback read `page.props`, which the callback
|
|
21
|
+
is not given.
|
|
22
|
+
|
|
23
|
+
`renderPage(Component, props, { shared })` builds the tree through Inertia's own
|
|
24
|
+
`<App>`, so `usePage()`, `<Head>` and a persistent layout all behave as they do in
|
|
25
|
+
the browser, and lets whatever it throws escape. It is not a DOM — `useEffect` does
|
|
26
|
+
not run — which is the point: it proves the tree _builds_, which is what nothing
|
|
27
|
+
else checked.
|
|
28
|
+
|
|
29
|
+
### Documented
|
|
30
|
+
|
|
31
|
+
- **[Persistent layouts](/docs/inertia/rendering#persistent-layouts)**, which this
|
|
32
|
+
package documented nowhere — not in the README, not in `api-surface.md`. The
|
|
33
|
+
callback is handed the page **element**, not the page props, so the natural
|
|
34
|
+
`(page) => <Layout search={page.props.search}>{page}</Layout>` throws on the first
|
|
35
|
+
paint and only in a browser. The page now shows the `usePage()` form and says why
|
|
36
|
+
the wrong one typechecks.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **`zt inertia:build` fails when it produces nothing.** A build could report success
|
|
41
|
+
with zero artefacts, and that is the shape that reaches production: the deploy sees
|
|
42
|
+
exit 0, restarts, and serves a page with no script and no stylesheet. The health
|
|
43
|
+
check passes — the server is fine and the HTML is fine, there is just nothing in
|
|
44
|
+
it. An app had to assert the files exist in its own deploy script to catch it.
|
|
45
|
+
|
|
11
46
|
## [1.10.0] — 2026-08-30
|
|
12
47
|
|
|
13
48
|
### Fixed
|
package/api-surface.md
CHANGED
|
@@ -319,3 +319,13 @@ type RenderArgs = [props?: Record<string, unknown>]
|
|
|
319
319
|
type RenderProps = { [x: string]: unknown;}
|
|
320
320
|
|
|
321
321
|
type RouteTable = Readonly<Record<string, string>> | ReadonlyMap<string, string>
|
|
322
|
+
|
|
323
|
+
## ./testing `(./src/testing.ts)`
|
|
324
|
+
|
|
325
|
+
function renderPage = (component: unknown, props?: Record<string, unknown>, options?: RenderPageOptions) => Promise<string>
|
|
326
|
+
|
|
327
|
+
interface RenderPageOptions = {
|
|
328
|
+
component?: string | undefined
|
|
329
|
+
shared?: Record<string, unknown> | undefined
|
|
330
|
+
url?: string | undefined
|
|
331
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/inertia",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"main": "./src/index.ts",
|
|
9
9
|
"types": "./src/index.ts",
|
|
10
10
|
"exports": {
|
|
11
|
-
".": "./src/index.ts"
|
|
11
|
+
".": "./src/index.ts",
|
|
12
|
+
"./testing": "./src/testing.ts"
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
|
14
15
|
"CHANGELOG.md",
|
|
@@ -33,7 +34,7 @@
|
|
|
33
34
|
"typecheck": "tsc --noEmit"
|
|
34
35
|
},
|
|
35
36
|
"dependencies": {
|
|
36
|
-
"@zerotal/core": "1.
|
|
37
|
+
"@zerotal/core": "1.11.0"
|
|
37
38
|
},
|
|
38
39
|
"peerDependencies": {
|
|
39
40
|
"react": "^18 || ^19",
|
|
@@ -79,6 +79,20 @@ export class InertiaBuildCommand extends Command {
|
|
|
79
79
|
throw new Error("Frontend build failed.");
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// `success` with no artefacts is not a build, and it is the shape that reaches
|
|
83
|
+
// production: a deploy runs this, sees exit 0, restarts, and serves a page with
|
|
84
|
+
// no script and no stylesheet. The health check passes — the server is fine, the
|
|
85
|
+
// HTML is fine, there is simply nothing in it. An app had to assert the files
|
|
86
|
+
// exist in its own deploy script to catch it, which is the framework's job.
|
|
87
|
+
if (result.outputs.length === 0) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Frontend build reported success and produced no files ` +
|
|
90
|
+
`(entry point: resources/js/app.tsx). An empty output directory serves a ` +
|
|
91
|
+
`page with no script and no stylesheet, which a health check cannot tell ` +
|
|
92
|
+
`from a working one.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
// Chunks are named after their content, so the ones this build replaced
|
|
83
97
|
// would otherwise stay behind — and ship.
|
|
84
98
|
//
|
package/src/ssr/renderPage.ts
CHANGED
|
@@ -182,6 +182,23 @@ export async function _prepareReactRender(
|
|
|
182
182
|
_importInertiaReact(inertiaReactSpecifier),
|
|
183
183
|
]);
|
|
184
184
|
|
|
185
|
+
return _elementFor(reactMod, inertiaReact, page, pageMod.default);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Wrap an already-resolved page component in Inertia's `<App>`.
|
|
190
|
+
*
|
|
191
|
+
* Split out so a test can render a component it imported directly, without a pages
|
|
192
|
+
* directory or a module path — see `@zerotal/inertia/testing`.
|
|
193
|
+
*
|
|
194
|
+
* @internal
|
|
195
|
+
*/
|
|
196
|
+
function _elementFor(
|
|
197
|
+
reactMod: ReactModule,
|
|
198
|
+
inertiaReact: InertiaReactModule,
|
|
199
|
+
page: SsrPage,
|
|
200
|
+
component: unknown,
|
|
201
|
+
): PreparedReactRender {
|
|
185
202
|
let head: string[] = [];
|
|
186
203
|
|
|
187
204
|
// `<App>` owns the head manager, the page context and the layout resolution —
|
|
@@ -191,8 +208,8 @@ export async function _prepareReactRender(
|
|
|
191
208
|
// whichever renderer we were handed to has produced its shell.
|
|
192
209
|
const element = reactMod.createElement(inertiaReact.App, {
|
|
193
210
|
initialPage: page,
|
|
194
|
-
initialComponent:
|
|
195
|
-
resolveComponent: () =>
|
|
211
|
+
initialComponent: component,
|
|
212
|
+
resolveComponent: () => component,
|
|
196
213
|
onHeadUpdate: (elements: string[]) => {
|
|
197
214
|
head = elements;
|
|
198
215
|
},
|
|
@@ -201,6 +218,26 @@ export async function _prepareReactRender(
|
|
|
201
218
|
return { element, head: () => head };
|
|
202
219
|
}
|
|
203
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Build the `<App>` element for a component the caller already has.
|
|
223
|
+
*
|
|
224
|
+
* @param page - The `{ component, props, url }` page to render.
|
|
225
|
+
* @param component - The page component itself.
|
|
226
|
+
* @internal
|
|
227
|
+
*/
|
|
228
|
+
export async function _prepareComponentRender(
|
|
229
|
+
page: SsrPage,
|
|
230
|
+
component: unknown,
|
|
231
|
+
): Promise<PreparedReactRender> {
|
|
232
|
+
const reactSpecifier = "react";
|
|
233
|
+
const inertiaReactSpecifier = "@inertiajs/react";
|
|
234
|
+
const [reactMod, inertiaReact] = await Promise.all([
|
|
235
|
+
import(reactSpecifier) as Promise<ReactModule>,
|
|
236
|
+
_importInertiaReact(inertiaReactSpecifier),
|
|
237
|
+
]);
|
|
238
|
+
return _elementFor(reactMod, inertiaReact, page, component);
|
|
239
|
+
}
|
|
240
|
+
|
|
204
241
|
/**
|
|
205
242
|
* Import `@inertiajs/react`, turning "not installed" into a sentence that says what
|
|
206
243
|
* to do about it.
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@zerotal/inertia/testing` — render a page component the way a browser will.
|
|
3
|
+
*
|
|
4
|
+
* ## The gap this fills
|
|
5
|
+
*
|
|
6
|
+
* `assertInertia("home")` proves the *server* named a component and handed it props.
|
|
7
|
+
* It proves nothing about the component. A page can throw on its first paint — a
|
|
8
|
+
* destructured prop that is not there, a layout callback reading `page.props` that
|
|
9
|
+
* the callback never receives — and the route still answers `200` with a correct
|
|
10
|
+
* payload, because the throw happens in a browser that the test never opened.
|
|
11
|
+
*
|
|
12
|
+
* An app shipped a blank `/mail` to production with **614 passing tests**. Every one
|
|
13
|
+
* of them asserted a value or a status code. The console said
|
|
14
|
+
* `Cannot read properties of undefined (reading 'search')`, from a layout callback,
|
|
15
|
+
* on a page whose Inertia payload was perfect.
|
|
16
|
+
*
|
|
17
|
+
* {@link renderPage} proves one thing and one thing only: that the component tree
|
|
18
|
+
* can be built without throwing. That is precisely the thing nothing else checks,
|
|
19
|
+
* and it is about forty lines an app should not have to write.
|
|
20
|
+
*
|
|
21
|
+
* ## What it does not do
|
|
22
|
+
*
|
|
23
|
+
* It is not a DOM. Nothing here clicks, and `useEffect` does not run — this is
|
|
24
|
+
* `renderToString`, so it exercises the render pass. For assertions about behaviour
|
|
25
|
+
* after paint, use the browser harness in `@zerotal/testing/browser`.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* import { renderPage } from "@zerotal/inertia/testing";
|
|
30
|
+
* import Home from "../resources/js/pages/home.tsx";
|
|
31
|
+
*
|
|
32
|
+
* test("home renders", async () => {
|
|
33
|
+
* await renderPage(Home, { title: "Hello" });
|
|
34
|
+
* });
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* @module
|
|
38
|
+
*/
|
|
39
|
+
import { _prepareComponentRender } from "./ssr/renderPage.ts";
|
|
40
|
+
|
|
41
|
+
/** Extras a page render may need beyond its own props. */
|
|
42
|
+
export interface RenderPageOptions {
|
|
43
|
+
/**
|
|
44
|
+
* Shared props the app's `Inertia.share()` would have added — `auth`, `flash`,
|
|
45
|
+
* `errors`, and anything else a layout reads. A page that destructures one of
|
|
46
|
+
* these throws without it, which is a real failure but rarely the one you are
|
|
47
|
+
* testing for, so seed the shape your app actually shares.
|
|
48
|
+
*/
|
|
49
|
+
shared?: Record<string, unknown> | undefined;
|
|
50
|
+
/** The URL the page believes it is at. Some layouts branch on it. Default `"/"`. */
|
|
51
|
+
url?: string | undefined;
|
|
52
|
+
/** The component name recorded in the page object. Default `"page"`. */
|
|
53
|
+
component?: string | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Render an Inertia page component to HTML, throwing whatever it throws.
|
|
58
|
+
*
|
|
59
|
+
* Renders through `@inertiajs/react`'s own `<App>`, so `usePage()`, `<Head>` and a
|
|
60
|
+
* persistent layout all behave as they do in the browser — a layout attached with
|
|
61
|
+
* `Page.layout` is resolved and rendered too, which is the case worth catching.
|
|
62
|
+
*
|
|
63
|
+
* @param component - The page component, imported directly.
|
|
64
|
+
* @param props - The props the server would send. Merged over `options.shared`.
|
|
65
|
+
* @param options - Shared props, URL and component name.
|
|
66
|
+
* @returns The rendered HTML, for a `toContain` if you want one.
|
|
67
|
+
* @throws Whatever the component throws, unchanged — the point is that it surfaces.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```ts
|
|
71
|
+
* const html = await renderPage(Profile, { user }, { shared: { auth: { user } } });
|
|
72
|
+
* expect(html).toContain(user.name);
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export async function renderPage(
|
|
76
|
+
component: unknown,
|
|
77
|
+
props: Record<string, unknown> = {},
|
|
78
|
+
options: RenderPageOptions = {},
|
|
79
|
+
): Promise<string> {
|
|
80
|
+
const page = {
|
|
81
|
+
component: options.component ?? "page",
|
|
82
|
+
props: { ...(options.shared ?? {}), ...props },
|
|
83
|
+
url: options.url ?? "/",
|
|
84
|
+
version: "test",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const { element } = await _prepareComponentRender(page, component);
|
|
88
|
+
|
|
89
|
+
// Specifier via a variable: `react-dom/server` is an optional peer, and a Vue app
|
|
90
|
+
// type-checking this package must not be asked for a module it will never install.
|
|
91
|
+
const reactServerSpecifier = "react-dom/server";
|
|
92
|
+
const serverMod = (await import(reactServerSpecifier)) as {
|
|
93
|
+
renderToString: (element: unknown) => string;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return serverMod.renderToString(element);
|
|
97
|
+
}
|