@zerotal/inertia 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,50 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.9.0] — 2026-08-29
12
+
13
+ ### Fixed
14
+
15
+ - **A rebuilt bundle no longer 404s on a chunk the browser asks for.** `resources/js/app.tsx`
16
+ builds to `/assets/app.js` under that name every time, while `splitting: true` names each chunk
17
+ after its content. A rebuild therefore rewrites `app.js` to import `chunk-NEW.js` and prunes
18
+ `chunk-OLD.js` — and a browser holding a cached `app.js` asks for the pruned one and gets
19
+
20
+ GET /assets/chunk-hrnspqda.js status=404
21
+
22
+ from a page that renders and a server that is healthy. Nothing in that line leads back to the
23
+ template.
24
+
25
+ The template hardcodes `/assets/app.js` rather than calling `asset()`, so the version token the
26
+ rest of the framework appends never reached it, and cache-busting had only ever been
27
+ implemented for `--dev-worker`. It now applies everywhere: the file's mtime in dev, where a
28
+ rebuild happens without a restart, and the boot-derived asset version otherwise, memoised
29
+ because a deploy restarts the process. An unchanged asset keeps a stable URL and stays cached,
30
+ which is why the token is derived rather than random.
31
+
32
+ ### Documented
33
+
34
+ - **Every promised export is documented.** The `docs-coverage` gate reads `maturity: stable` as a
35
+ promise about a package's exports, and measures how much of that promise is written down. It
36
+ was 798 gaps across the suite; it is now zero. This package's share is covered on its own
37
+ pages — types named, options shapes described, and the decisions behind them recorded where
38
+ somebody looking for them will find them.
39
+
40
+ ### Added
41
+
42
+ - **A warning when a model crosses into page props having declared no boundary.** Page props
43
+ are page source: everything handed to `inertia()` is serialised into the document, and
44
+ `return inertia("Trips/Show", { trip })` is what a newcomer writes on their first afternoon
45
+ and ships the whole row — the internal cost, the margin, the note about the customer, on
46
+ the customer's own screen. Nothing fails and the page looks right, which makes it the one
47
+ mistake here that never announces itself.
48
+
49
+ The ORM's `hidden` / `visible` lists were already honoured, since they are applied by
50
+ `toJSON()` and that is what serialises a prop — nothing said so. In development, passing a
51
+ model that declares neither list now names the model and the number of fields it is about
52
+ to publish. It fires once per model class and goes quiet as soon as either list exists, so
53
+ the normal case of passing models stays quiet.
54
+
11
55
  ## [1.8.0] — 2026-08-24
12
56
 
13
57
  ### Fixed
package/api-surface.md CHANGED
@@ -256,12 +256,6 @@ interface InertiaDevtoolsConfig = {
256
256
 
257
257
  interface InertiaPageRegistry = {}
258
258
 
259
- interface InertiaProviderOptions = {
260
- assetsUrl?: string
261
- htmlTemplate?: string
262
- version?: string
263
- }
264
-
265
259
  interface MergeConfig = {
266
260
  appendPaths: string[]
267
261
  deep: boolean
@@ -303,18 +297,6 @@ interface PaginatorLike = {
303
297
  total?: number
304
298
  }
305
299
 
306
- interface ResolvedPage = {
307
- deepMergeProps?: string[]
308
- deferredProps?: Record<string, string[]>
309
- matchPropsOn?: string[]
310
- mergeProps?: string[]
311
- onceProps?: Record<string, { prop: string; expiresAt: number | null;}>
312
- prependProps?: string[]
313
- props: Record<string, unknown>
314
- rescuedProps?: string[]
315
- scrollProps?: Record<string, ScrollConfig>
316
- }
317
-
318
300
  interface ScrollConfig = {
319
301
  currentPage: number | null
320
302
  nextPage: number | null
@@ -328,8 +310,6 @@ type PageName = never
328
310
 
329
311
  type PageTarget = string
330
312
 
331
- type PropFactory = () => T | Promise<T>
332
-
333
313
  type PropInput = T | (() => T | Promise<T>) | AlwaysProp<T> | MergeProp<T> | (T extends PaginatorLike ? InfiniteScrollProp : never) | (undefined extends T ? OptionalProp<T> | DeferProp<T> : never)
334
314
 
335
315
  type PropsOf = PageComponent<N> extends (props: infer Props, ...rest: any[]) => any ? Props : PageComponent<N> extends abstract new (props: infer Props, ...rest: any[]) => any ? Props : Record<string, unknown>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/inertia",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -33,7 +33,7 @@
33
33
  "typecheck": "tsc --noEmit"
34
34
  },
35
35
  "dependencies": {
36
- "@zerotal/core": "1.8.1"
36
+ "@zerotal/core": "1.9.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "react": "^18 || ^19",
package/src/inertia.ts CHANGED
@@ -1,10 +1,12 @@
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";
@@ -81,6 +83,12 @@ export async function buildPageObject(
81
83
  // a no-op unless the DevTools recorder opened a recording for this request.
82
84
  recordPage(component, merged, resolved.props, resolved.rescuedProps);
83
85
 
86
+ // Everything below this line is page source. Last chance to say so — and the
87
+ // only place that sees the props as they will actually be serialised, after
88
+ // partial reloads and lazy wrappers have decided what is really going out.
89
+ // Development only; a no-op on a served request.
90
+ checkPropBoundary(resolved.props);
91
+
84
92
  const history = readHistoryFlags();
85
93
  if (history.encryptHistory) page.encryptHistory = true;
86
94
  if (history.clearHistory) page.clearHistory = true;
@@ -103,6 +111,9 @@ let _pagesDir = "";
103
111
  */
104
112
  export function _setHtmlTemplate(html: string): void {
105
113
  _htmlTemplate = html;
114
+ // The busted copy was derived from the previous template. Keeping it would
115
+ // serve the old markup for as long as the asset token happened to match.
116
+ _resetBustedTemplate();
106
117
  }
107
118
 
108
119
  /**
@@ -136,31 +147,70 @@ export function _getPagesDir(): string {
136
147
  return _pagesDir || `${process.cwd()}/${config.safe("inertia.pagesDir", DEFAULT_PAGES_DIR)}`;
137
148
  }
138
149
 
150
+ /** Local `href="/…"` / `src="/…"` JS and CSS URLs that carry no query already. */
151
+ const _LOCAL_ASSET_URL = /((?:href|src)=")(\/[^"?]+\.(?:js|css))(")/g;
152
+
139
153
  /**
140
- * In dev (`--dev-worker`) append a cache-busting `?v=<mtime>` to local JS/CSS
141
- * asset URLs in the served HTML, so the browser fetches a freshly-rebuilt
142
- * bundle instead of a stale cached copy.
154
+ * Append a cache-busting `?v=…` to the template's local JS/CSS URLs.
155
+ *
156
+ * **The entry point is not content-hashed and the chunks are**, which is the
157
+ * whole reason this exists. `app.tsx` builds to `/assets/app.js` under that name
158
+ * every time, while `splitting: true` names each chunk after its content — so a
159
+ * rebuild rewrites `app.js` to import `chunk-NEW.js` and prunes `chunk-OLD.js`.
160
+ * A browser holding a cached `app.js` then asks for a chunk that is no longer on
161
+ * disk and gets a 404, from a page that renders fine and a server that is
162
+ * perfectly healthy. The stack says `GET /assets/chunk-….js 404` and names
163
+ * nothing that would lead you here.
164
+ *
165
+ * The template hardcodes `/assets/app.js` rather than calling `asset()`, so the
166
+ * version token the rest of the framework appends never reached it. Two token
167
+ * sources, because the two runtimes invalidate at different moments:
143
168
  *
144
- * The token is the asset file's modification time: unchanged assets keep a
145
- * stable URL (and stay cached), while a rebuild changes the URL and forces a
146
- * re-fetch. No-op in production, where assets are served as-is.
169
+ * - **Dev (`--dev-worker`)**: the file's mtime, read per request. A rebuild
170
+ * happens without a restart, so a token fixed at boot would be stale exactly
171
+ * when it matters.
172
+ * - **Everywhere else**: the boot-derived asset version, memoised. A deploy
173
+ * restarts the process — the pipeline says so in as many words — so the token
174
+ * is computed once and reused, rather than stat-ing files on every render.
175
+ *
176
+ * Unchanged assets keep a stable URL and stay cached; that is the point of a
177
+ * derived token rather than a random one.
147
178
  *
148
179
  * @internal
149
180
  */
150
- function _devBustAssets(html: string): string {
151
- if (!process.argv.includes("--dev-worker")) return html;
152
- const root = `${process.cwd()}/public`;
153
- return html.replace(
154
- /((?:href|src)=")(\/[^"?]+\.(?:js|css))(")/g,
155
- (match, pre: string, url: string, post: string) => {
181
+ export function _bustAssets(html: string): string {
182
+ if (process.argv.includes("--dev-worker")) {
183
+ const root = `${process.cwd()}/public`;
184
+ return html.replace(_LOCAL_ASSET_URL, (match, pre: string, url: string, post: string) => {
156
185
  try {
157
186
  const mtime = Math.floor(statSync(`${root}${url}`).mtimeMs);
158
187
  return `${pre}${url}?v=${mtime}${post}`;
159
188
  } catch {
160
189
  return match; // asset not found under public/ — leave the URL untouched
161
190
  }
162
- },
163
- );
191
+ });
192
+ }
193
+
194
+ const token = coreAssetVersion();
195
+ // No token means nothing derived one — an app serving no built assets, or a
196
+ // boot order that has not reached the conventions phase. Leave the URLs alone
197
+ // rather than stamping `?v=` and inventing a second URL for the same file.
198
+ if (!token) return html;
199
+ if (_bustedFor === token) return _bustedHtml;
200
+
201
+ _bustedHtml = html.replace(_LOCAL_ASSET_URL, `$1$2?v=${token}$3`);
202
+ _bustedFor = token;
203
+ return _bustedHtml;
204
+ }
205
+
206
+ /** Memoised output of {@link _bustAssets}, keyed on the token it was built with. */
207
+ let _bustedHtml = "";
208
+ let _bustedFor = "";
209
+
210
+ /** Drop the memoised template. Tests, and any caller that replaces the template. @internal */
211
+ export function _resetBustedTemplate(): void {
212
+ _bustedHtml = "";
213
+ _bustedFor = "";
164
214
  }
165
215
 
166
216
  /**
@@ -215,7 +265,7 @@ async function _inertiaStream(component: string, props: Record<string, unknown>)
215
265
 
216
266
  const pageObject = await buildPageObject(component, props);
217
267
 
218
- const [prefix = "", suffix = ""] = _devBustAssets(_htmlTemplate).split("<!-- @inertia -->");
268
+ const [prefix = "", suffix = ""] = _bustAssets(_htmlTemplate).split("<!-- @inertia -->");
219
269
 
220
270
  const { modPath, framework } = await resolvePageModule(_getPagesDir(), component);
221
271
  const encoder = new TextEncoder();
@@ -394,7 +444,7 @@ async function _inertia(component: string, props: Record<string, unknown>): Prom
394
444
  .replace(/&/g, "\\u0026")
395
445
  .replace(/\//g, "\\/");
396
446
 
397
- const html = _devBustAssets(_htmlTemplate).replace(
447
+ const html = _bustAssets(_htmlTemplate).replace(
398
448
  "<!-- @inertia -->",
399
449
  `<div id="app"></div>\n ` +
400
450
  `<script type="application/json" data-page="app">${safeJson}</script>`,
@@ -14,6 +14,8 @@
14
14
  * Generic so a wrapper can carry what it will resolve to — that is what lets
15
15
  * `Inertia.render` check `defer(() => stats())` against the `stats` prop the
16
16
  * page component declares, instead of checking that *something* was passed.
17
+ *
18
+ * @internal
17
19
  */
18
20
  export type PropFactory<T = unknown> = () => T | Promise<T>;
19
21
 
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The boundary between a model and a page prop.
3
+ *
4
+ * Inertia page props are page source. Everything handed to `inertia()` is
5
+ * serialised into the HTML document — or returned as JSON on an XHR visit — and
6
+ * anybody who views source reads all of it. That is not a leak in itself; it is
7
+ * how the protocol works. It becomes a leak because of what the obvious code does:
8
+ *
9
+ * ```ts
10
+ * return inertia("Trips/Show", { trip }); // the first thing anyone writes
11
+ * ```
12
+ *
13
+ * `trip` is a model, and a model serialises the row. Every column. The internal
14
+ * cost, the margin, the note somebody left about the customer — all of it, in the
15
+ * page, on the customer's own screen. Nothing fails, nothing logs, and the page
16
+ * looks right.
17
+ *
18
+ * The ORM already has the answer: `static hidden` and `static visible` are honoured
19
+ * by `toJSON()`, which is what serialises a prop. Declaring the dangerous columns
20
+ * once at the model is strictly better than remembering a projection at every call
21
+ * site. What was missing is anything that says so at the moment it matters.
22
+ *
23
+ * So this warns — in development only, once per model class — when a model reaches
24
+ * page props having declared neither list. Not "you passed a model", which is
25
+ * normal and fine, but "you passed a model that has never said which of its columns
26
+ * are safe to publish". A model with either list declared is silent forever after.
27
+ *
28
+ * @module
29
+ */
30
+ import { deployEnv, isDevSurfaceAllowed } from "@zerotal/core";
31
+
32
+ /** A value that serialises like an ORM model. */
33
+ interface ModelLike {
34
+ toJSON(): unknown;
35
+ constructor: { name: string; hidden?: unknown; visible?: unknown };
36
+ }
37
+
38
+ /**
39
+ * Model classes already reported. Keyed by the class itself, so two models with the
40
+ * same name are two findings and a hot path never re-formats a warning nobody
41
+ * needs to read twice.
42
+ */
43
+ const _warned = new WeakSet<object>();
44
+
45
+ /** How deep to look for models inside props. */
46
+ const MAX_DEPTH = 4;
47
+
48
+ /** How many values to look at per level, so a large collection cannot cost the request. */
49
+ const MAX_BREADTH = 50;
50
+
51
+ /**
52
+ * How many fields a value publishes when serialised, or `null` when it is not the
53
+ * kind of thing this check is about.
54
+ *
55
+ * Structural, not `instanceof BaseModel`: `@zerotal/inertia` must not depend on
56
+ * `@zerotal/orm`, and an app can serve Inertia pages with no ORM installed at all.
57
+ * Three conditions together are specific enough — a class instance (not a plain
58
+ * object, which is already a projection), carrying its own `toJSON`, whose
59
+ * `toJSON` returns an object with fields in it.
60
+ *
61
+ * That last one is doing real work. `Date` is a class instance with a `toJSON`,
62
+ * and so are `URL` and the Temporal types; every one of them serialises to a
63
+ * string, and a string has no columns to leak. Requiring an object result is what
64
+ * separates "a row went into the page" from "a timestamp did".
65
+ */
66
+ function serialisedFieldCount(value: unknown): number | null {
67
+ if (value === null || typeof value !== "object") return null;
68
+ if (Array.isArray(value)) return null;
69
+ const prototype = Object.getPrototypeOf(value) as object | null;
70
+ if (prototype === null || prototype === Object.prototype) return null;
71
+ if (typeof (value as { toJSON?: unknown }).toJSON !== "function") return null;
72
+ let json: unknown;
73
+ try {
74
+ json = (value as ModelLike).toJSON();
75
+ } catch {
76
+ // A model whose serialisation throws — an unloaded relation, most likely —
77
+ // has a problem this check is not the right place to report.
78
+ return null;
79
+ }
80
+ if (json === null || typeof json !== "object" || Array.isArray(json)) return null;
81
+ const fields = Object.keys(json).length;
82
+ return fields > 0 ? fields : null;
83
+ }
84
+
85
+ /** Whether a model's class has said anything at all about what is safe to publish. */
86
+ function declaresBoundary(model: ModelLike): boolean {
87
+ const { hidden, visible } = model.constructor;
88
+ return (
89
+ (Array.isArray(hidden) && hidden.length > 0) || (Array.isArray(visible) && visible.length > 0)
90
+ );
91
+ }
92
+
93
+ /**
94
+ * The warning text. Names the model, says how much of it is about to be published,
95
+ * and gives the one-line fix rather than a principle.
96
+ */
97
+ export function propBoundaryWarning(name: string, columns: number, propKey: string): string {
98
+ return (
99
+ `[inertia] \`${name}\` was passed as the \`${propKey}\` prop and declares neither ` +
100
+ `\`hidden\` nor \`visible\`, so all ${columns} of its serialised fields are written into ` +
101
+ `page source — readable by anyone who views source on this page.\n` +
102
+ ` If that is intended, say so once and this goes quiet:\n` +
103
+ ` static hidden: Columns<${name}>[] = ["cost_cents", "internal_notes"];\n` +
104
+ ` static visible: Columns<${name}>[] = ["id", "title"]; // or an allow-list\n` +
105
+ ` Both are honoured by toJSON(), which is what serialises this prop.`
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Warn about models in page props that have never declared a boundary.
111
+ *
112
+ * Development only, and a no-op everywhere else — it walks the resolved prop tree,
113
+ * which is not work a served request should be doing.
114
+ *
115
+ * @param props - The resolved props, as they will be serialised.
116
+ * @param report - Where to write findings. Injectable for tests.
117
+ */
118
+ export function checkPropBoundary(
119
+ props: Record<string, unknown>,
120
+ report: (message: string) => void = (m) => console.warn(m),
121
+ ): void {
122
+ if (!isDevSurfaceAllowed(deployEnv())) return;
123
+
124
+ const visit = (value: unknown, propKey: string, depth: number, seen: WeakSet<object>): void => {
125
+ if (depth > MAX_DEPTH || value === null || typeof value !== "object") return;
126
+ // Props can hold the same model twice, and a loaded relation can point back at
127
+ // its parent. Either would otherwise be walked forever.
128
+ if (seen.has(value)) return;
129
+ seen.add(value);
130
+
131
+ if (Array.isArray(value)) {
132
+ for (const item of value.slice(0, MAX_BREADTH)) visit(item, propKey, depth + 1, seen);
133
+ return;
134
+ }
135
+
136
+ const fields = serialisedFieldCount(value);
137
+ if (fields !== null) {
138
+ const model = value as ModelLike;
139
+ if (!declaresBoundary(model) && !_warned.has(model.constructor)) {
140
+ _warned.add(model.constructor);
141
+ report(propBoundaryWarning(model.constructor.name, fields, propKey));
142
+ }
143
+ return;
144
+ }
145
+
146
+ // A plain object — a paginator, a `{ data: [...] }` envelope, a hand-built
147
+ // projection that happens to carry a model on one key.
148
+ for (const nested of Object.values(value).slice(0, MAX_BREADTH)) {
149
+ visit(nested, propKey, depth + 1, seen);
150
+ }
151
+ };
152
+
153
+ for (const [key, value] of Object.entries(props)) {
154
+ visit(value, key, 0, new WeakSet<object>());
155
+ }
156
+ }
157
+
158
+ /** Forget every class already reported. @internal For tests. */
159
+ export function _resetPropBoundaryWarnings(classes: object[]): void {
160
+ for (const cls of classes) _warned.delete(cls);
161
+ }
@@ -7,7 +7,11 @@ import {
7
7
  type ScrollConfig,
8
8
  } from "./PropTypes.ts";
9
9
 
10
- /** The resolved props plus the page-object metadata the client needs to merge/defer correctly. */
10
+ /**
11
+ * The resolved props plus the page-object metadata the client needs to merge/defer correctly.
12
+ *
13
+ * @internal
14
+ */
11
15
  export interface ResolvedPage {
12
16
  props: Record<string, unknown>;
13
17
  deferredProps?: Record<string, string[]>;
package/src/types.ts CHANGED
@@ -42,7 +42,11 @@ export interface PageObject {
42
42
  sharedProps?: string[];
43
43
  }
44
44
 
45
- /** Options passed to `InertiaProvider` to configure the adapter at boot. */
45
+ /**
46
+ * Options passed to `InertiaProvider` to configure the adapter at boot.
47
+ *
48
+ * @internal
49
+ */
46
50
  export interface InertiaProviderOptions {
47
51
  /** Path to the HTML template. Default: 'resources/app.html' */
48
52
  htmlTemplate?: string;