@zerotal/inertia 1.7.4 → 1.8.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,20 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.8.0] — 2026-08-24
12
+
13
+ ### Fixed
14
+
15
+ - **A `303` redirect was not marked as Inertia's, so the browser did nothing at all.**
16
+ `X-Inertia: true` was set inside the 302-to-303 conversion, so it only ever reached a
17
+ redirect arriving as a 301 or 302 from a non-GET handler. A handler returning the 303 the
18
+ protocol asks for skipped the only line that marked its response — and `redirect(to, 303)`
19
+ is exactly what `docs/authentication.md` tells people to write, in eight places. The form
20
+ submitted, the row was written, the mail went out, and the fields stayed filled in: a hang
21
+ from both ends. Marking now happens for every redirect status on an Inertia request, with
22
+ the conversion a separate decision on top of it. `307` and `308` are marked but left alone,
23
+ since preserving the method is the whole reason to choose them.
24
+
11
25
  ## [1.7.1] — 2026-08-16
12
26
 
13
27
  ### Fixed
package/api-surface.md CHANGED
@@ -188,16 +188,10 @@ const inertiaStream = PageRenderer
188
188
 
189
189
  const route = RouteBuilder
190
190
 
191
- function _getHtmlTemplate = () => string
192
-
193
- function _setHtmlTemplate = (html: string) => void
194
-
195
191
  function always = { <T>(value: PropFactory<T>): AlwaysProp<T>; <T>(value: T): AlwaysProp<T>;}
196
192
 
197
193
  function assetVersion = () => string
198
194
 
199
- function buildPageObject = (component: string, props: Record<string, unknown>) => Promise<PageObject>
200
-
201
195
  function clearHistory = () => void
202
196
 
203
197
  function deepMerge = { <T>(value: PropFactory<T>): MergeProp<T>; <T>(value: T): MergeProp<T>;}
@@ -206,14 +200,10 @@ function defer = <T>(callback: PropFactory<T>, group?: string, options?: { re
206
200
 
207
201
  function defineRoutes = (table: RouteTable) => void
208
202
 
209
- function detectVuePlugin = (cwd: string) => Promise<BunPlugin[]>
210
-
211
203
  function devtoolsEnabled = () => boolean
212
204
 
213
205
  function encryptHistory = (on?: boolean) => void
214
206
 
215
- function generatePageRegistry = (cwd?: string, pagesDir?: string) => Promise<void>
216
-
217
207
  function hasRoute = (name: string) => boolean
218
208
 
219
209
  function InertiaConfig = (options?: Partial<InertiaConfigShape>) => InertiaConfigShape
@@ -230,18 +220,10 @@ function optional = <T>(callback: PropFactory<T>) => OptionalProp<T>
230
220
 
231
221
  function resetRoutes = () => void
232
222
 
233
- function resolveProps = (raw: Record<string, unknown>, headers: Headers, component: string) => Promise<ResolvedPage>
234
-
235
223
  function scroll = (value: PaginatorLike | PropFactory, options?: { pageName?: string; dataPath?: string;}) => InfiniteScrollProp
236
224
 
237
- function setAssetVersion = (v: string) => void
238
-
239
- function setHistoryEncryptionDefault = (on: boolean) => void
240
-
241
225
  function share = { (key: string, value: unknown): void; (values: Record<string, unknown>): void;}
242
226
 
243
- function sharedProps = () => Record<string, unknown>
244
-
245
227
  interface DevtoolsEntry = {
246
228
  __meta: { id: string; method: string; url: string; status: number; requestType: DevtoolsRequestType; component: string | null; timestamp: string; utime: number; tabUuid: string | null; batchId: string | null; serverTimingMs: number | null; redirectLocation?: string | null; visitId?: string | null;}
247
229
  componentPath: string | null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/inertia",
3
- "version": "1.7.4",
3
+ "version": "1.8.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.7.4"
36
+ "@zerotal/core": "1.8.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "react": "^18 || ^19",
@@ -1,5 +1,5 @@
1
1
  import { Command } from "@zerotal/core";
2
- import { pruneBuildOutput, browserEnvDefines } from "@zerotal/core/dev";
2
+ import { pruneBuildOutput, cleanBuildOutput, browserEnvDefines } from "@zerotal/core/dev";
3
3
  import { generatePageRegistry } from "../PageRegistry.ts";
4
4
  import { detectCssPlugins } from "../css.ts";
5
5
  import { detectVuePlugin } from "../vuePlugin.ts";
@@ -32,6 +32,15 @@ export class InertiaBuildCommand extends Command {
32
32
  description: "Build for production (minified, no source maps)",
33
33
  default: false,
34
34
  },
35
+ {
36
+ name: "clean",
37
+ short: "c",
38
+ type: "boolean" as const,
39
+ description:
40
+ "Delete everything in public/assets that this build did not write. " +
41
+ "Without it, only chunks and files the last build on THIS machine recorded are removed",
42
+ default: false,
43
+ },
35
44
  ];
36
45
 
37
46
  override async run(): Promise<void> {
@@ -72,7 +81,17 @@ export class InertiaBuildCommand extends Command {
72
81
 
73
82
  // Chunks are named after their content, so the ones this build replaced
74
83
  // would otherwise stay behind — and ship.
75
- const removed = await pruneBuildOutput(outdir, result.outputs);
84
+ //
85
+ // Which of the two runs matters most on a build machine that starts clean
86
+ // every time. The prune's record of the last build lives in `.zerotal/`,
87
+ // which is gitignored, so a fresh checkout has nothing to compare against
88
+ // and only chunk-named files are recognised. `--clean` needs no record: this
89
+ // directory belongs to the build, and what the build did not write does not
90
+ // belong in it.
91
+ const clean = this.flags["clean"] as boolean;
92
+ const removed = clean
93
+ ? await cleanBuildOutput(outdir, result.outputs)
94
+ : await pruneBuildOutput(outdir, result.outputs);
76
95
 
77
96
  this.info(`Build complete: ${result.outputs.length} files → public/assets/`);
78
97
  if (removed.length > 0) this.dim(` Removed ${removed.length} stale file(s).`);
@@ -21,6 +21,15 @@ import { assetVersion } from "../version.ts";
21
21
  * 3. Always set Vary: X-Inertia on all responses
22
22
  * Ensures browser cache treats HTML and JSON versions as distinct.
23
23
  */
24
+ /**
25
+ * Every status the client can be handed as a redirect.
26
+ *
27
+ * 307 and 308 are here to be *marked*, not converted — an app that picks a
28
+ * method-preserving redirect means it, and a response the client cannot
29
+ * recognise as Inertia's is the failure this list exists to prevent.
30
+ */
31
+ const REDIRECT_STATUSES = [301, 302, 303, 307, 308];
32
+
24
33
  export class InertiaMiddleware extends BaseMiddleware {
25
34
  protected options: Record<string, never> = {};
26
35
 
@@ -51,34 +60,46 @@ export class InertiaMiddleware extends BaseMiddleware {
51
60
 
52
61
  const status = response.status;
53
62
  const method = http.request.method;
54
- const isRedirect = [301, 302, 303].includes(status);
63
+ const isRedirect = REDIRECT_STATUSES.includes(status);
55
64
 
56
- // Fragment redirects: a redirect whose target carries a URL fragment (#...) on an Inertia
57
- // request becomes a 409 + X-Inertia-Redirect, so the client performs a standard Inertia visit
58
- // (preserving the fragment) instead of a full reload.
59
65
  if (isInertia && isRedirect) {
60
66
  const target = response.headers.get("Location") ?? "";
67
+
68
+ // Fragment redirects: a redirect whose target carries a URL fragment (#...) on an Inertia
69
+ // request becomes a 409 + X-Inertia-Redirect, so the client performs a standard Inertia visit
70
+ // (preserving the fragment) instead of a full reload.
61
71
  if (target.includes("#")) {
62
- // Same header-preservation concern as the 303 branch below.
72
+ // Same header-preservation concern as the redirect branch below.
63
73
  const headers = new Headers(response.headers);
64
74
  headers.delete("Location");
65
75
  headers.set("X-Inertia-Redirect", target);
66
76
  headers.set("X-Inertia", "true");
67
77
  return new Response(null, { status: 409, headers });
68
78
  }
69
- }
70
79
 
71
- // Convert 302 to 303 for non-GET Inertia redirects
72
- // Inertia requires 303 so browsers use GET on the redirect target
73
- if (isInertia && [301, 302].includes(status) && method !== "GET") {
74
80
  // Carry the original headers over. Rebuilding the Response from just `Location` dropped
75
81
  // every other header the handler set — most importantly `Set-Cookie`, so `POST /login`
76
82
  // returned a 303 to /dashboard with the session cookie discarded and the user still
77
83
  // logged out.
78
84
  const headers = new Headers(response.headers);
79
- headers.set("Location", response.headers.get("Location") ?? "/");
85
+ headers.set("Location", target || "/");
86
+
87
+ // Stamped on *every* Inertia redirect, not only the ones converted below.
88
+ //
89
+ // It used to be set inside the conversion, which meant a handler that
90
+ // already returned the 303 the protocol asks for — `http.redirect(to, 303)`
91
+ // — skipped the only line that marked the response as Inertia's. The
92
+ // request succeeded, the row was written, and the form sat there with the
93
+ // fields still filled in: the worst shape a failure can take, because
94
+ // nothing about it looks like an error from either end.
80
95
  headers.set("X-Inertia", "true");
81
- return new Response(null, { status: 303, headers });
96
+ if (!headers.has("Vary")) headers.set("Vary", "X-Inertia");
97
+
98
+ // A non-GET 301/302 becomes a 303, so the browser follows with GET instead
99
+ // of repeating the method against the target. 307 and 308 are left alone:
100
+ // preserving the method is the whole reason to choose them.
101
+ const needsSeeOther = method !== "GET" && (status === 301 || status === 302);
102
+ return new Response(null, { status: needsSeeOther ? 303 : status, headers });
82
103
  }
83
104
 
84
105
  // Never wrap streaming responses (e.g. SSE) — re-creating the Response