@zerotal/inertia 1.4.0 → 1.5.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
@@ -4,10 +4,86 @@ All notable changes to this package are documented here. The format is
4
4
  based on [Keep a Changelog](https://keepachangelog.com/); this package
5
5
  follows the Zerotal monorepo's unified versioning.
6
6
 
7
- **Maturity: `beta`**
7
+ **Maturity: `stable`**
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.5.0] — 2026-08-15
12
+
13
+ ### Added
14
+
15
+ - **Typed pages: `Inertia.render(component, props)` is checked against the page component.**
16
+ The name must be a page that exists — a renamed or misspelled component was a runtime
17
+ 500 before, the kind that reaches production because the route it lives on is the one
18
+ nobody clicked — and the props are checked against the props that component declares.
19
+
20
+ Nothing new is annotated to make this work. `resources/js/pages.generated.ts` already
21
+ holds an `import()` thunk per page, and an `import()` thunk carries the module's full
22
+ type; the file's `Record<string, () => Promise<{ default: unknown }>>` annotation was
23
+ throwing all of it away — widening every page name to `string` and every default export
24
+ to `unknown`, which is precisely what typed props need. It is now written with
25
+ `satisfies`: same constraint enforced, types kept, nothing changed at runtime. One
26
+ type-only `declare module` line in that same file hands them to the server, which keeps
27
+ the server's only view of the client component graph in exactly one place.
28
+
29
+ The check runs in the cheap direction: the **component** declares its props (which a
30
+ React component does anyway) and the **controller** is checked against them.
31
+
32
+ Three details worth knowing:
33
+
34
+ - **Wrappers are unwrapped, and carry their payload.** A prop accepts its value, a
35
+ factory, or a wrapper — and `merge(() => [1, 2])` for a `Post[]` prop is an error.
36
+ - **`optional()` and `defer()` are rejected on a required prop.** They are absent on
37
+ first paint by definition, so a component typing such a prop as required is wrong
38
+ about its own contract; a type that accepted it would launder that bug into something
39
+ the compiler had signed off on. Declare the prop `?`.
40
+ - **Shared props are never required.** `auth`/`flash`/`errors`/`old` are merged in, so a
41
+ page declaring one does not force every controller to pass it. Declare your own
42
+ `Inertia.share()` keys on the `SharedProps` interface and they behave the same way.
43
+
44
+ Vue pages are checked by **name** only: a `.vue` SFC resolves through a
45
+ `declare module '*.vue'` shim whose default export is `DefineComponent<{}, {}, any>`, so
46
+ its props are invisible to TypeScript without `vue-tsc` in the typecheck path. Those
47
+ pages accept any props rather than failing on a shape nobody can see — documented rather
48
+ than silently degraded.
49
+
50
+ - **`Inertia.render.dynamic(name, props)` / `inertia.dynamic()`** — render a page whose
51
+ name is only known at runtime (an error page chosen by status code, a component from
52
+ config), with no checking. A separate function rather than a `string` overload: an
53
+ overload that accepts every string is matched by every string, which would make the
54
+ checked signature decorative. `inertiaStream.dynamic()` is its streaming counterpart.
55
+
56
+ - **The prop wrappers are generic.** `optional`, `lazy`, `always`, `defer`, `merge` and
57
+ `deepMerge` carry what they resolve to (`defer(() => stats())` is a `DeferProp<Stats>`),
58
+ which is what lets the wrapper be checked against the prop it fills. Existing calls are
59
+ unaffected — the parameter defaults to `unknown`.
60
+
61
+ - **Tests for `PrecognitionMiddleware`.** A precognitive request validates a form
62
+ without running the controller's side effects, answering 204 or 422 instead of the
63
+ real response — so those answers must never be confused with real ones by a cache.
64
+ Without the `Vary`, a shared cache can serve a precognitive 204 to an actual form
65
+ submission, and the user's data silently never reaches the controller. Pinned:
66
+ the header is added only for `Precognition: true` (not `1`, not `TRUE`), it is
67
+ appended to an existing `Vary` rather than replacing it — replacing would discard
68
+ the app's own content negotiation — it is not duplicated when already present, and
69
+ the decorated response keeps its status and body. 76 tests → 84.
70
+
71
+ - **The prop wrapper classes, the error classes and `SsrHandler` are documented.**
72
+ `OptionalProp`, `AlwaysProp`, `DeferProp`, `MergeProp` and `InfiniteScrollProp` are
73
+ what the prop helpers return; the reference now says so, and names `InertiaProp` as
74
+ the type to accept for "any wrapped prop". `InertiaError`, `InvalidComponentError`
75
+ and `InertiaTemplateNotLoadedError` gained a table of what throws each.
76
+
77
+ ### Changed
78
+
79
+ - **`detectVuePlugin` is marked `@internal`** — build plumbing for `inertia:build`,
80
+ reached only by the build command. With the eight markers already present the
81
+ promise is 30 exports, not 39.
82
+
83
+ - **Maturity is now `stable`** — the public API follows SemVer strictly for the rest
84
+ of the 1.x line. Every promised export is documented across seven guide pages, and
85
+ the only dependency is `@zerotal/core`.
86
+
11
87
  ## [1.0.3] — 2026-08-07
12
88
 
13
89
  ### Changed
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@zerotal/inertia",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "license": "MIT",
5
- "maturity": "beta",
5
+ "maturity": "stable",
6
6
  "private": false,
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
@@ -32,7 +32,7 @@
32
32
  "typecheck": "tsc --noEmit"
33
33
  },
34
34
  "dependencies": {
35
- "@zerotal/core": "1.4.0"
35
+ "@zerotal/core": "1.5.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "react": "^18 || ^19",
@@ -85,9 +85,23 @@ export async function generatePageRegistry(
85
85
  "// Bun.build with splitting:true creates one .js chunk per page.",
86
86
  "// Converting to static imports will bundle ALL pages into app.js.",
87
87
  "",
88
- "export const pages: Record<string, () => Promise<{ default: unknown }>> = {",
88
+ // `satisfies`, not `: Record<string, …>`. The annotation checked the shape and
89
+ // then threw away everything else the thunks knew — the page names widened to
90
+ // `string` and every default export to `unknown`, which is precisely what
91
+ // typed props need. `satisfies` enforces the same constraint and keeps the
92
+ // types. Nothing changes at runtime.
93
+ "export const pages = {",
89
94
  ...thunks,
90
- "};",
95
+ "} satisfies Record<string, () => Promise<{ default: unknown }>>;",
96
+ "",
97
+ "// Type-only: lets a controller's `Inertia.render(name, props)` be checked",
98
+ "// against the props each page component declares. Erased at runtime — and",
99
+ "// it is the ONLY place the server side touches the client component graph.",
100
+ 'declare module "@zerotal/inertia" {',
101
+ " interface InertiaPageRegistry {",
102
+ " pages: typeof pages;",
103
+ " }",
104
+ "}",
91
105
  // Trailing newline: the file is written on every dev rebuild, and without it
92
106
  // a formatter in the app would rewrite it right back, forever.
93
107
  "",
package/src/augment.ts CHANGED
@@ -1,19 +1,23 @@
1
1
  import type { MiddlewareClass, RouteRegistration } from "@zerotal/core";
2
+ import type { PageTarget, RenderProps } from "./pages.ts";
2
3
 
3
4
  declare module "@zerotal/core" {
4
5
  interface RouterMacros {
5
6
  /**
6
7
  * Register a GET route that renders an Inertia page without a controller.
7
8
  *
9
+ * The component name and props are checked against the generated page
10
+ * registry, exactly as `Inertia.render` is.
11
+ *
8
12
  * @example
9
13
  * Router.inertia('/about', 'About/Index');
10
14
  * Router.inertia('/home', 'Home/Index', { greeting: 'Hello' });
11
15
  * Router.inertia('/admin', 'Admin/Dashboard', [AuthMiddleware]);
12
16
  */
13
- inertia(
17
+ inertia<N extends PageTarget>(
14
18
  path: string,
15
- component: string,
16
- props?: Record<string, unknown> | MiddlewareClass[],
19
+ component: N,
20
+ props?: RenderProps<N> | MiddlewareClass[],
17
21
  middleware?: MiddlewareClass[],
18
22
  ): RouteRegistration;
19
23
  }
package/src/index.ts CHANGED
@@ -50,12 +50,25 @@ export {
50
50
  _setHtmlTemplate,
51
51
  _getHtmlTemplate,
52
52
  } from "./inertia.ts";
53
+ export type { PageRenderer } from "./inertia.ts";
53
54
  export { InertiaProvider } from "./provider/InertiaProvider.ts";
54
55
  export { InertiaMiddleware } from "./middleware/InertiaMiddleware.ts";
55
56
  export { PrecognitionMiddleware } from "./middleware/PrecognitionMiddleware.ts";
56
57
  export { sharedProps } from "./SharedProps.ts";
57
58
  export { assetVersion, setAssetVersion } from "./version.ts";
58
59
  export { generatePageRegistry } from "./PageRegistry.ts";
60
+ // The typed page registry: `InertiaPageRegistry` is what `pages.generated.ts`
61
+ // augments, `SharedProps` is what the app declares for `Inertia.share()`.
62
+ export type {
63
+ InertiaPageRegistry,
64
+ SharedProps,
65
+ PageName,
66
+ PageTarget,
67
+ PropsOf,
68
+ PropInput,
69
+ RenderProps,
70
+ RenderArgs,
71
+ } from "./pages.ts";
59
72
  export { detectVuePlugin } from "./vuePlugin.ts";
60
73
  export type { PageObject, InertiaProviderOptions } from "./types.ts";
61
74
 
package/src/inertia.ts CHANGED
@@ -9,6 +9,35 @@ import { readHistoryFlags } from "./historyState.ts";
9
9
  import { allSharedKeys } from "./share.ts";
10
10
  import { resolvePageModule, renderInertiaPage } from "./ssr/renderPage.ts";
11
11
  import type { PageObject } from "./types.ts";
12
+ import type { PageTarget, RenderArgs } from "./pages.ts";
13
+
14
+ /**
15
+ * A page-render helper — {@link inertia} and {@link inertiaStream} — with the
16
+ * `dynamic` escape hatch beside its checked call signature.
17
+ */
18
+ export interface PageRenderer {
19
+ /**
20
+ * Render page `component` with props checked against that page's component.
21
+ *
22
+ * @param component - Page component name/path relative to the pages dir, without extension.
23
+ * @param args - Props for the page; omit when the page requires none.
24
+ */
25
+ <N extends PageTarget>(component: N, ...args: RenderArgs<N>): Promise<void>;
26
+
27
+ /**
28
+ * Render a page whose name isn't known at compile time — an error page chosen
29
+ * by status code, a component name from config or the database.
30
+ *
31
+ * A separate function rather than a `string` overload: an overload that
32
+ * accepts every string is matched by every string, which would make the
33
+ * checked signature decorative. This one is greppable and says what it is
34
+ * giving up.
35
+ *
36
+ * @param component - Page component name, resolved at runtime.
37
+ * @param props - Props for the page, unchecked.
38
+ */
39
+ dynamic(component: string, props?: Record<string, unknown>): Promise<void>;
40
+ }
12
41
 
13
42
  /**
14
43
  * Build the full Inertia page object for the current request: merges shared props, runs the v3
@@ -147,7 +176,7 @@ function _devBustAssets(html: string): string {
147
176
  * Reach it from a controller via `Inertia.stream(...)`.
148
177
  *
149
178
  * @param component - Page component name (path relative to the pages dir, no extension), e.g. `"Posts/Show"`.
150
- * @param props - Props passed to the page; may include prop wrappers (`optional`/`defer`/`merge`/…).
179
+ * @param args - Props passed to the page; may include prop wrappers (`optional`/`defer`/`merge`/…). Checked against the page component's own props, like {@link inertia}.
151
180
  * @throws {@link InertiaTemplateNotLoadedError} When the HTML template has not been loaded (InertiaProvider not registered).
152
181
  * @throws {@link InvalidComponentError} When the component name contains path-traversal sequences.
153
182
  *
@@ -159,10 +188,16 @@ function _devBustAssets(html: string): string {
159
188
  * }
160
189
  * ```
161
190
  */
162
- export async function inertiaStream(
163
- component: string,
164
- props: Record<string, unknown> = {},
165
- ): Promise<void> {
191
+ export const inertiaStream: PageRenderer = Object.assign(
192
+ <N extends PageTarget>(component: N, ...args: RenderArgs<N>): Promise<void> =>
193
+ _inertiaStream(component, (args[0] ?? {}) as Record<string, unknown>),
194
+ {
195
+ dynamic: (component: string, props: Record<string, unknown> = {}): Promise<void> =>
196
+ _inertiaStream(component, props),
197
+ },
198
+ );
199
+
200
+ async function _inertiaStream(component: string, props: Record<string, unknown>): Promise<void> {
166
201
  const ctx = RequestContext.get();
167
202
 
168
203
  if (!_htmlTemplate) {
@@ -290,8 +325,16 @@ export async function inertiaStream(
290
325
  * For streaming SSR (better TTFB) use {@link inertiaStream} instead; to force a
291
326
  * full-page/external redirect use {@link location}.
292
327
  *
328
+ * @remarks
329
+ * Both arguments are type-checked against the generated page registry
330
+ * (`resources/js/pages.generated.ts`): the component name must be a page that
331
+ * exists, and the props are checked against the props that page's component
332
+ * declares, with `optional`/`defer`/`merge` wrappers unwrapped. Shared props
333
+ * are optional — the framework merges them in. Until the registry is rebuilt,
334
+ * any name and any props compile, as before.
335
+ *
293
336
  * @param component - Page component name/path relative to the pages dir, without extension (e.g. `"Users/Index"`).
294
- * @param props - Props for the page. Values may be plain data or prop wrappers (`optional`/`defer`/`merge`/`scroll`/…). Defaults to `{}`.
337
+ * @param args - Props for the page. Values may be plain data, a factory, or prop wrappers (`optional`/`defer`/`merge`/`scroll`/…). Omit when the page needs none.
295
338
  * @returns A promise that resolves once `ctx.response` has been set (no value).
296
339
  * @throws {@link InertiaTemplateNotLoadedError} On a full-page load when the HTML template has not been loaded (InertiaProvider not registered).
297
340
  *
@@ -304,10 +347,16 @@ export async function inertiaStream(
304
347
  * }
305
348
  * ```
306
349
  */
307
- export async function inertia(
308
- component: string,
309
- props: Record<string, unknown> = {},
310
- ): Promise<void> {
350
+ export const inertia: PageRenderer = Object.assign(
351
+ <N extends PageTarget>(component: N, ...args: RenderArgs<N>): Promise<void> =>
352
+ _inertia(component, (args[0] ?? {}) as Record<string, unknown>),
353
+ {
354
+ dynamic: (component: string, props: Record<string, unknown> = {}): Promise<void> =>
355
+ _inertia(component, props),
356
+ },
357
+ );
358
+
359
+ async function _inertia(component: string, props: Record<string, unknown>): Promise<void> {
311
360
  const ctx = RequestContext.get();
312
361
  const isInertiaRequest = ctx.request.headers.get("X-Inertia") === "true";
313
362
 
@@ -22,7 +22,7 @@ import { assetVersion } from "../version.ts";
22
22
  * Ensures browser cache treats HTML and JSON versions as distinct.
23
23
  */
24
24
  export class InertiaMiddleware extends BaseMiddleware {
25
- protected options: {} = {};
25
+ protected options: Record<string, never> = {};
26
26
 
27
27
  async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
28
28
  const isInertia = http.request.headers.get("X-Inertia") === "true";
@@ -13,7 +13,7 @@ import { BaseMiddleware, withHeaders } from "@zerotal/core";
13
13
  * Register it globally (before route middleware) when using precognitive forms.
14
14
  */
15
15
  export class PrecognitionMiddleware extends BaseMiddleware {
16
- protected options: {} = {};
16
+ protected options: Record<string, never> = {};
17
17
 
18
18
  async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
19
19
  const isPrecognitive = http.request.headers.get("Precognition") === "true";
package/src/pages.ts ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The typed page registry: what turns `Inertia.render("Users/Index", props)`
3
+ * from two strings and a bag of `unknown` into a checked call.
4
+ *
5
+ * The generated `resources/js/pages.generated.ts` already knows everything
6
+ * needed — it holds one `() => import("./pages/Users/Index.tsx")` thunk per
7
+ * page, and an `import()` thunk carries the module's full type. It used to be
8
+ * annotated `Record<string, () => Promise<{ default: unknown }>>`, which
9
+ * enforced the shape and discarded the rest. Written with `satisfies` instead,
10
+ * the same file names every page and every page's props.
11
+ *
12
+ * It reaches the server through one declaration-merged interface:
13
+ *
14
+ * ```ts
15
+ * declare module "@zerotal/inertia" {
16
+ * interface InertiaPageRegistry { pages: typeof pages }
17
+ * }
18
+ * ```
19
+ *
20
+ * That indirection is the point. `pages.generated.ts` lives in `resources/js`
21
+ * and imports `.tsx` files; a controller that imported it directly would drag
22
+ * the whole component graph into the server's type-check, and a broken
23
+ * component would fail the server's build. The augmentation is type-only, so
24
+ * the coupling exists in exactly one file and nothing is emitted.
25
+ *
26
+ * **The direction is the good part.** The page component declares its props —
27
+ * which a React component does anyway — and the controller is checked against
28
+ * them. Neither side writes an annotation it wasn't already writing.
29
+ */
30
+ import type {
31
+ AlwaysProp,
32
+ DeferProp,
33
+ InfiniteScrollProp,
34
+ MergeProp,
35
+ OptionalProp,
36
+ PaginatorLike,
37
+ } from "./props/PropTypes.ts";
38
+
39
+ /**
40
+ * Augmentation target for the generated page map. Filled in by
41
+ * `resources/js/pages.generated.ts` with `{ pages: typeof pages }`.
42
+ *
43
+ * Empty until then, which is why every Inertia helper keeps a `string`
44
+ * fallback: an app that has not rebuilt its registry still compiles.
45
+ *
46
+ * @category Extension registries
47
+ */
48
+ export interface InertiaPageRegistry {}
49
+
50
+ /**
51
+ * Props merged into every page by the framework, so a controller never passes
52
+ * them. Augment it to match your app's `Inertia.share()` calls and the props
53
+ * they contribute become optional in `Inertia.render` rather than missing:
54
+ *
55
+ * ```ts
56
+ * declare module "@zerotal/inertia" {
57
+ * interface SharedProps {
58
+ * auth: { user: { id: number; name: string } | null };
59
+ * appName: string;
60
+ * }
61
+ * }
62
+ * ```
63
+ *
64
+ * Hand-written on purpose. `share(key, value)` is a runtime call in a provider,
65
+ * so no generator can see it; the list is small, stable, and app-specific.
66
+ *
67
+ * @category Extension registries
68
+ */
69
+ export interface SharedProps {}
70
+
71
+ /**
72
+ * The shared props the framework merges on every request whatever the app does
73
+ * — see `sharedProps()`. Kept separate from {@link SharedProps} rather than
74
+ * declared in it: an app that types `auth.user` as its own `User` would
75
+ * otherwise be redeclaring a member of the same interface, which is a merge
76
+ * conflict rather than a refinement.
77
+ */
78
+ interface BuiltInSharedProps {
79
+ auth: { user: unknown };
80
+ flash: { success: string | null; error: string | null };
81
+ errors: Record<string, string>;
82
+ old: Record<string, unknown>;
83
+ }
84
+
85
+ /** Everything a controller may leave out because something else supplies it. */
86
+ type AllSharedProps = SharedProps & BuiltInSharedProps;
87
+
88
+ /** The generated `pages` map, or an empty map before the registry exists. */
89
+ type PageModules = InertiaPageRegistry extends { pages: infer Map } ? Map : Record<never, never>;
90
+
91
+ /** Every page name in the generated registry. `never` until it is generated. */
92
+ export type PageName = Extract<keyof PageModules, string>;
93
+
94
+ /**
95
+ * What the Inertia helpers accept as a component name: the generated page names
96
+ * once the registry exists, any string before that.
97
+ */
98
+ export type PageTarget = [PageName] extends [never] ? string : PageName;
99
+
100
+ /** The default export of page `N`'s module. */
101
+ type PageComponent<N extends string> = N extends keyof PageModules
102
+ ? PageModules[N] extends () => Promise<{ default: infer Component }>
103
+ ? Component
104
+ : unknown
105
+ : unknown;
106
+
107
+ /**
108
+ * The props a component declares, read off its own signature.
109
+ *
110
+ * Structural on purpose — `@zerotal/inertia` must not import React types (React
111
+ * is an optional peer; a Vue app never installs it). A function component's
112
+ * first parameter and a class component's constructor prop both match here.
113
+ *
114
+ * Anything else — a `React.memo()` wrapper, or a `.vue` SFC resolved through a
115
+ * `declare module '*.vue'` shim that types the default export as
116
+ * `DefineComponent<{}, {}, any>` — has no readable props, and falls back to an
117
+ * open record so those pages keep compiling rather than failing on a shape
118
+ * nobody can see. See the Vue note in the props docs.
119
+ */
120
+ type PropsOfComponent<Component> = Component extends (
121
+ props: infer Props,
122
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matching any component signature, whose extra args (React's legacy context) are irrelevant here.
123
+ ...rest: any[]
124
+ ) => // eslint-disable-next-line @typescript-eslint/no-explicit-any -- a component returns JSX, ReactNode, a Promise of either…
125
+ any
126
+ ? Props
127
+ : Component extends abstract new (
128
+ props: infer Props,
129
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
130
+ ...rest: any[]
131
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
132
+ ) => any
133
+ ? Props
134
+ : Record<string, unknown>;
135
+
136
+ /** The props page `N`'s component declares. */
137
+ export type PropsOf<N extends string> = PropsOfComponent<PageComponent<N>>;
138
+
139
+ /**
140
+ * What a controller may pass for a prop the component declares as `T`.
141
+ *
142
+ * The two sides genuinely differ: the controller passes `optional(() => posts)`
143
+ * and the component receives `Post[]`. So each prop accepts its value, a
144
+ * factory for it, or a wrapper carrying it.
145
+ *
146
+ * **`optional` and `defer` are only allowed where `T` admits `undefined`.**
147
+ * They are absent on first paint by definition, so a component that declares
148
+ * the prop as required is wrong about its own contract — and a type that
149
+ * accepted them anyway would launder that bug into something the compiler
150
+ * signed off on. `always`, `merge` and `scroll` are present on first paint, so
151
+ * they are allowed everywhere.
152
+ */
153
+ export type PropInput<T> =
154
+ | T
155
+ | (() => T | Promise<T>)
156
+ | AlwaysProp<T>
157
+ | MergeProp<T>
158
+ // `scroll()` resolves to the paginator it was given, so it only fits a prop
159
+ // the component declares as paginator-shaped.
160
+ | (T extends PaginatorLike ? InfiniteScrollProp : never)
161
+ | (undefined extends T ? OptionalProp<T> | DeferProp<T> : never);
162
+
163
+ /** Apply {@link PropInput} to every prop, preserving optional and readonly modifiers. */
164
+ type PropInputs<T> = { [K in keyof T]: PropInput<T[K]> };
165
+
166
+ /**
167
+ * The props `Inertia.render(N, …)` requires: everything page `N` declares, each
168
+ * accepting its wrappers — minus the shared props, which the framework merges
169
+ * in. Shared props stay *accepted* (a controller may override `auth` for one
170
+ * page) but are never required, and a page component that declares one doesn't
171
+ * make every controller pass it.
172
+ */
173
+ export type RenderProps<N extends string> = [PageName] extends [never]
174
+ ? Record<string, unknown>
175
+ : Omit<PropInputs<PropsOf<N>>, keyof AllSharedProps> & Partial<PropInputs<AllSharedProps>>;
176
+
177
+ /**
178
+ * `render()`'s arguments after the component name: the props bag is optional
179
+ * only when the page requires nothing of it.
180
+ */
181
+ export type RenderArgs<N extends string> = [PageName] extends [never]
182
+ ? [props?: Record<string, unknown>]
183
+ : // "every prop is optional" — an empty object satisfies the page.
184
+ Record<never, never> extends RenderProps<N>
185
+ ? [props?: RenderProps<N>]
186
+ : [props: RenderProps<N>];
@@ -8,8 +8,14 @@
8
8
  * Optional/Always/Defer/Merge prop wrappers, adapted to TypeScript.
9
9
  */
10
10
 
11
- /** A prop value: a concrete value, or a (possibly async) factory evaluated on demand. */
12
- export type PropFactory = () => unknown | Promise<unknown>;
11
+ /**
12
+ * A prop value: a concrete value, or a (possibly async) factory evaluated on demand.
13
+ *
14
+ * Generic so a wrapper can carry what it will resolve to — that is what lets
15
+ * `Inertia.render` check `defer(() => stats())` against the `stats` prop the
16
+ * page component declares, instead of checking that *something* was passed.
17
+ */
18
+ export type PropFactory<T = unknown> = () => T | Promise<T>;
13
19
 
14
20
  /** Resolved merge configuration contributed by a mergeable prop. */
15
21
  export interface MergeConfig {
@@ -120,12 +126,12 @@ export abstract class InertiaProp {
120
126
  *
121
127
  * @category Props
122
128
  */
123
- export class OptionalProp extends InertiaProp {
129
+ export class OptionalProp<T = unknown> extends InertiaProp {
124
130
  override readonly ignoreFirstLoad = true;
125
- constructor(private readonly callback: PropFactory) {
131
+ constructor(private readonly callback: PropFactory<T>) {
126
132
  super();
127
133
  }
128
- resolve(): unknown | Promise<unknown> {
134
+ resolve(): T | Promise<T> {
129
135
  return this.callback();
130
136
  }
131
137
  }
@@ -136,12 +142,12 @@ export class OptionalProp extends InertiaProp {
136
142
  *
137
143
  * @category Props
138
144
  */
139
- export class AlwaysProp extends InertiaProp {
140
- constructor(private readonly value: unknown | PropFactory) {
145
+ export class AlwaysProp<T = unknown> extends InertiaProp {
146
+ constructor(private readonly value: T | PropFactory<T>) {
141
147
  super();
142
148
  }
143
- resolve(): unknown | Promise<unknown> {
144
- return typeof this.value === "function" ? (this.value as PropFactory)() : this.value;
149
+ resolve(): T | Promise<T> {
150
+ return typeof this.value === "function" ? (this.value as PropFactory<T>)() : this.value;
145
151
  }
146
152
  }
147
153
 
@@ -152,16 +158,16 @@ export class AlwaysProp extends InertiaProp {
152
158
  *
153
159
  * @category Props
154
160
  */
155
- export class DeferProp extends InertiaProp {
161
+ export class DeferProp<T = unknown> extends InertiaProp {
156
162
  override readonly ignoreFirstLoad = true;
157
163
  constructor(
158
- private readonly callback: PropFactory,
164
+ private readonly callback: PropFactory<T>,
159
165
  readonly group: string = "default",
160
166
  readonly rescue: boolean = false,
161
167
  ) {
162
168
  super();
163
169
  }
164
- resolve(): unknown | Promise<unknown> {
170
+ resolve(): T | Promise<T> {
165
171
  return this.callback();
166
172
  }
167
173
  }
@@ -172,13 +178,13 @@ export class DeferProp extends InertiaProp {
172
178
  *
173
179
  * @category Props
174
180
  */
175
- export class MergeProp extends InertiaProp {
176
- constructor(private readonly value: unknown | PropFactory) {
181
+ export class MergeProp<T = unknown> extends InertiaProp {
182
+ constructor(private readonly value: T | PropFactory<T>) {
177
183
  super();
178
184
  this._merge = true;
179
185
  }
180
- resolve(): unknown | Promise<unknown> {
181
- return typeof this.value === "function" ? (this.value as PropFactory)() : this.value;
186
+ resolve(): T | Promise<T> {
187
+ return typeof this.value === "function" ? (this.value as PropFactory<T>)() : this.value;
182
188
  }
183
189
  }
184
190
 
@@ -256,7 +262,7 @@ export class InfiniteScrollProp extends InertiaProp {
256
262
  * });
257
263
  * ```
258
264
  */
259
- export function optional(callback: PropFactory): OptionalProp {
265
+ export function optional<T>(callback: PropFactory<T>): OptionalProp<T> {
260
266
  return new OptionalProp(callback);
261
267
  }
262
268
 
@@ -267,7 +273,7 @@ export function optional(callback: PropFactory): OptionalProp {
267
273
  * @returns An {@link OptionalProp} wrapper.
268
274
  * @category Props
269
275
  */
270
- export function lazy(callback: PropFactory): OptionalProp {
276
+ export function lazy<T>(callback: PropFactory<T>): OptionalProp<T> {
271
277
  return new OptionalProp(callback);
272
278
  }
273
279
 
@@ -279,7 +285,12 @@ export function lazy(callback: PropFactory): OptionalProp {
279
285
  * @returns An {@link AlwaysProp} wrapper.
280
286
  * @category Props
281
287
  */
282
- export function always(value: unknown | PropFactory): AlwaysProp {
288
+ // Two overloads, not one `T | PropFactory<T>` parameter: given a callback, a
289
+ // single union parameter lets TypeScript infer `T` as the callback itself, and
290
+ // the wrapper would then advertise a function where the page expects data.
291
+ export function always<T>(value: PropFactory<T>): AlwaysProp<T>;
292
+ export function always<T>(value: T): AlwaysProp<T>;
293
+ export function always<T>(value: T | PropFactory<T>): AlwaysProp<T> {
283
294
  return new AlwaysProp(value);
284
295
  }
285
296
 
@@ -302,11 +313,11 @@ export function always(value: unknown | PropFactory): AlwaysProp {
302
313
  * });
303
314
  * ```
304
315
  */
305
- export function defer(
306
- callback: PropFactory,
316
+ export function defer<T>(
317
+ callback: PropFactory<T>,
307
318
  group = "default",
308
319
  options: { rescue?: boolean } = {},
309
- ): DeferProp {
320
+ ): DeferProp<T> {
310
321
  return new DeferProp(callback, group, options.rescue ?? false);
311
322
  }
312
323
 
@@ -323,7 +334,9 @@ export function defer(
323
334
  * return inertia('Feed', { posts: merge(() => Post.paginate(15, page)) });
324
335
  * ```
325
336
  */
326
- export function merge(value: unknown | PropFactory): MergeProp {
337
+ export function merge<T>(value: PropFactory<T>): MergeProp<T>;
338
+ export function merge<T>(value: T): MergeProp<T>;
339
+ export function merge<T>(value: T | PropFactory<T>): MergeProp<T> {
327
340
  return new MergeProp(value);
328
341
  }
329
342
 
@@ -335,7 +348,9 @@ export function merge(value: unknown | PropFactory): MergeProp {
335
348
  * @returns A {@link MergeProp} wrapper configured for deep merging.
336
349
  * @category Props
337
350
  */
338
- export function deepMerge(value: unknown | PropFactory): MergeProp {
351
+ export function deepMerge<T>(value: PropFactory<T>): MergeProp<T>;
352
+ export function deepMerge<T>(value: T): MergeProp<T>;
353
+ export function deepMerge<T>(value: T | PropFactory<T>): MergeProp<T> {
339
354
  return new MergeProp(value).deepMerge();
340
355
  }
341
356
 
package/src/route.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Router } from "@zerotal/core";
2
2
  import type { MiddlewareClass, RouteRegistration } from "@zerotal/core";
3
3
  import { inertia } from "./inertia.ts";
4
+ import type { PageTarget, RenderProps } from "./pages.ts";
4
5
 
5
6
  /**
6
7
  * Register a GET route that renders an Inertia page directly — no controller
@@ -22,10 +23,10 @@ import { inertia } from "./inertia.ts";
22
23
  * Router.inertia('/admin', 'Admin/Dashboard', [AuthMiddleware]);
23
24
  * ```
24
25
  */
25
- export function inertiaRoute(
26
+ export function inertiaRoute<N extends PageTarget>(
26
27
  path: string,
27
- component: string,
28
- props?: Record<string, unknown> | MiddlewareClass[],
28
+ component: N,
29
+ props?: RenderProps<N> | MiddlewareClass[],
29
30
  middleware: MiddlewareClass[] = [],
30
31
  ): RouteRegistration {
31
32
  let resolvedProps: Record<string, unknown> = {};
@@ -34,12 +35,15 @@ export function inertiaRoute(
34
35
  if (Array.isArray(props)) {
35
36
  resolvedMiddleware = props; // shorthand: Router.inertia('/x', 'X', [AuthMiddleware])
36
37
  } else if (props) {
37
- resolvedProps = props;
38
+ resolvedProps = props as Record<string, unknown>;
38
39
  }
39
40
 
40
41
  const handler = class InertiaRouteHandler {
41
42
  async handle(): Promise<void> {
42
- await inertia(component, resolvedProps);
43
+ // Props were checked against the component above; the runtime call takes
44
+ // the erased bag, which `RenderProps<N>` cannot be proven to be while `N`
45
+ // is still a type variable.
46
+ await inertia.dynamic(component, resolvedProps);
43
47
  }
44
48
  };
45
49
 
package/src/vuePlugin.ts CHANGED
@@ -38,6 +38,8 @@ interface SfcDescriptor {
38
38
  /**
39
39
  * Resolve `@vue/compiler-sfc` from `cwd` and return a Bun plugin that compiles
40
40
  * `.vue` files. Returns `[]` when the compiler is not installed.
41
+ *
42
+ * @internal Build plumbing for `inertia:build`; apps do not call it.
41
43
  */
42
44
  export async function detectVuePlugin(cwd: string): Promise<BunPlugin[]> {
43
45
  let compilerPath: string;