@blacklizard/inertia-express 1.2.0 → 3.0.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/README.md CHANGED
@@ -17,7 +17,7 @@ The internal core is framework-agnostic, so future adapters for Fastify, Hono, N
17
17
  - Full implementation of the Inertia.js v3 protocol
18
18
  - `res.inertia(component, props, options)` Express helper
19
19
  - Shared props (static or per-request)
20
- - Lazy, optional, deferred, always, merge, and deep-merge props
20
+ - Optional, deferred (with rescue), always, merge, deep-merge, once, and infinite-scroll props
21
21
  - Partial reloads (`X-Inertia-Partial-Data` / `X-Inertia-Partial-Except`)
22
22
  - Asset version handling and automatic 409 + `X-Inertia-Location` on mismatch
23
23
  - Inertia-aware redirects (auto 303 after PUT/PATCH/DELETE)
@@ -126,40 +126,169 @@ inertia({
126
126
  });
127
127
  ```
128
128
 
129
- ## Lazy, optional, deferred, always, and merge props
129
+ ## Prop helpers
130
+
131
+ Every prop value is either a plain value, a bare closure (evaluated on each
132
+ response that includes it, awaited if it returns a promise), or a descriptor
133
+ built by one of the constructors below. Descriptors are chainable and mirror
134
+ inertia-laravel's prop classes; the resolver is a port of its `PropsResolver`.
135
+
136
+ ```ts
137
+ import { always, deepMerge, defer, merge, once, optional, scroll } from "@blacklizard/inertia-express";
138
+ ```
139
+
140
+ A bare closure replaces 2.x's `lazy()`:
141
+
142
+ ```ts
143
+ app.get("/dashboard", async (req, res) => {
144
+ await res.inertia("Dashboard", {
145
+ user: req.user,
146
+ recent: () => userService.recent(),
147
+ });
148
+ });
149
+ ```
150
+
151
+ ### `optional`
152
+
153
+ Omitted from the first load; resolved only when a partial reload asks for it.
154
+
155
+ ```ts
156
+ app.get("/reports", async (req, res) => {
157
+ await res.inertia("Reports", {
158
+ heavy: optional(() => analytics.expensiveQuery()),
159
+ });
160
+ });
161
+ ```
162
+
163
+ Laravel: `Inertia::optional()`
164
+
165
+ ### `defer`
166
+
167
+ Omitted from the first load; the client fetches it in a follow-up request with
168
+ the rest of its group (default `"default"`). `.rescue()` opts in to error
169
+ rescue: if the producer throws, the prop is dropped from the response, its path
170
+ is reported in `rescuedProps`, and the middleware's `onRescue` hook is called.
171
+
172
+ ```ts
173
+ app.get("/dashboard", async (req, res) => {
174
+ await res.inertia("Dashboard", {
175
+ stats: defer(() => dashboard.getStats(), "dashboard"),
176
+ feed: defer(() => feedService.load()).rescue(),
177
+ });
178
+ });
179
+ ```
180
+
181
+ Laravel: `Inertia::defer()`
182
+
183
+ ### `always`
184
+
185
+ Included on every response, even partial reloads that do not request it.
186
+ Takes a plain value or a closure.
187
+
188
+ ```ts
189
+ app.get("/inbox", async (req, res) => {
190
+ await res.inertia("Inbox", {
191
+ flash: always(() => req.session.flash),
192
+ });
193
+ });
194
+ ```
195
+
196
+ Laravel: `Inertia::always()`
197
+
198
+ ### `merge` / `deepMerge`
199
+
200
+ `merge(value)` tells the client to append the prop to its existing value at the
201
+ root; `deepMerge(value)` merges recursively. Chain `.append(path?, matchOn?)`
202
+ or `.prepend(path?, matchOn?)` to target nested paths, and `.matchOn(path)` to
203
+ dedupe items by a key instead of blindly appending. A path argument may be
204
+ `true` (the root, the default), a dotted path string, a list of paths, or a
205
+ `{ path: matchOnKey }` record. A client can send `X-Inertia-Reset: <key>` to
206
+ have a merge prop replaced instead of merged on that visit.
130
207
 
131
208
  ```ts
132
- import { lazy, optional, defer, always, merge, deepMerge } from "@blacklizard/inertia-express";
209
+ app.get("/notifications", async (req, res) => {
210
+ await res.inertia("Notifications", {
211
+ notifications: merge(() => db.notifications.page(req.query.page)).matchOn("id"),
212
+ feed: merge(() => feedService.page(req.query.page)).append("data", "id").prepend("pinned"),
213
+ filters: deepMerge({ sort: { field: "name" } }),
214
+ });
215
+ });
216
+ ```
133
217
 
134
- await res.inertia("Dashboard", {
135
- // Always evaluated.
136
- user: req.user,
218
+ Laravel: `Inertia::merge()` / `Inertia::deepMerge()`
137
219
 
138
- // Evaluated on every visit, but the function form lets you defer the work.
139
- recent: lazy(() => userService.recent()),
220
+ ### `once`
140
221
 
141
- // Omitted on full visits; only sent if requested via partial reload.
142
- heavy: optional(() => analytics.expensiveQuery()),
222
+ Resolved once and cached by the client across visits; later visits skip the
223
+ producer while the client still holds the value. `.as(key)` sets a custom cache
224
+ key (shareable across pages), `.until(delay)` sets an expiry (a number of
225
+ seconds, or a `Date`), and `.fresh()` forces re-resolution on this response.
226
+ `optional`, `defer` and `merge` descriptors also accept `.once()`.
143
227
 
144
- // Omitted on initial visit; the client automatically requests it via a
145
- // follow-up partial reload after mount, grouped by `"dashboard"`.
146
- stats: defer(() => dashboard.getStats(), "dashboard"),
228
+ ```ts
229
+ app.get("/settings", async (req, res) => {
230
+ await res.inertia("Settings", {
231
+ countries: once(() => geo.countries()).as("countries").until(3600),
232
+ plans: once(() => billing.plans()).until(new Date("2027-01-01")),
233
+ banner: once(() => cms.banner()).fresh(req.query.refresh === "1"),
234
+ });
235
+ });
236
+ ```
237
+
238
+ Laravel: `Inertia::once()`
147
239
 
148
- // Included on every response, even partial reloads that filter it out.
149
- flash: always(() => req.session.flash),
240
+ ### `scroll`
150
241
 
151
- // Merge with the existing client-side prop on partial reloads instead of
152
- // replacing it. Useful for paginated lists / infinite-scroll feeds. Pass a
153
- // match-on field to dedupe array items instead of blindly appending.
154
- notifications: merge(await db.notifications.page(req.query.page), "id"),
242
+ An infinite-scroll prop: merged at its `wrapper` key (default `"data"`) and
243
+ emitted with pagination metadata. `metadata` is required — either an object
244
+ `{ pageName, previousPage, nextPage, currentPage }` or a function deriving it
245
+ from the resolved value. The merge direction follows the client's
246
+ `X-Inertia-Infinite-Scroll-Merge-Intent` header (`prepend`, otherwise
247
+ `append`). Chain `.defer(group?)` to load the first page in a follow-up
248
+ request, and `.matchOn(path)` to dedupe.
155
249
 
156
- // Recursive deep-merge into the existing client-side prop.
157
- filters: deepMerge({ sort: { field: "name" } }),
250
+ ```ts
251
+ app.get("/posts", async (req, res) => {
252
+ await res.inertia("Posts", {
253
+ posts: scroll(() => postService.paginate(req.query.page), {
254
+ metadata: (page) => ({ pageName: "page", previousPage: page.prev, nextPage: page.next, currentPage: page.current }),
255
+ }).matchOn("id"),
256
+ comments: scroll(() => commentService.page(1), { wrapper: "items", metadata: { pageName: "page", previousPage: null, nextPage: 2, currentPage: 1 } }).defer(),
257
+ });
158
258
  });
159
259
  ```
160
260
 
161
- A client can send `X-Inertia-Reset: <key>` to have a merge / deep-merge prop
162
- replaced instead of merged on that visit.
261
+ Laravel: `Inertia::scroll()`
262
+
263
+ ### Rescue hook and fragment preservation
264
+
265
+ Pass `onRescue: (error, path, req) => …` to `inertia({ … })` to report errors
266
+ from rescued deferred props (the default logs them). Pass
267
+ `{ preserveFragment: true }` as the third argument to `res.inertia()` to have
268
+ the client keep the current URL `#fragment` across that visit.
269
+
270
+ ### Migrating from 2.x
271
+
272
+ | 2.x | 3.0 |
273
+ |---|---|
274
+ | `lazy(fn)` | `fn` (a bare closure) |
275
+ | `merge(value, matchOn)` | `merge(value).matchOn(matchOn)` |
276
+ | `deepMerge(value, matchOn)` | `deepMerge(value).matchOn(matchOn)` |
277
+ | `defer(fn, group)` | unchanged |
278
+ | `optional(fn)`, `always(fn)` | unchanged |
279
+ | `resolveProps()` → `{ props, deferred, merge, deepMerge, matchOn }` | `{ props, metadata }` |
280
+ | Types `MergeProp<T> = T & {...}` | descriptor types; the page `props` type is unchanged |
281
+
282
+ Other changes to check when upgrading:
283
+
284
+ - `merge(value, "id")` and `deepMerge(value, "id")` now throw a `TypeError` instead of silently ignoring the second argument. Use `.matchOn("id")`.
285
+ - Partial reloads follow dotted paths. `only: users.data` on a plain-object `users` now returns just `{ users: { data } }`; children of a closure or descriptor still bypass filtering and come back whole. `except: users.data` drops the whole top-level `users`, as in 2.x and in the upstream reference server; inertia-laravel keeps `users` and drops only the child, and the adapter deliberately differs from it here.
286
+ - Types: `DeferredProp` is renamed to `DeferProp`. `LazyProp`, `DeepMergeProp` and `PropValue` are removed. `MergeProp<T>` is now the descriptor interface returned by `merge()` / `deepMerge()`, not `T & {...}`.
287
+ - Core-entry adapter authors who build these objects by hand: `InertiaPage.preserveFragment` is now required, and `InertiaRequestInfo` gains the required fields `exceptOnceProps` and `mergeIntent`.
288
+ - `sharedProps` (the list of shared top-level keys) is now emitted on every page that has shared props.
289
+ - Descriptors are builders. Create them inside the handler for each request rather than mutating one hoisted to module level; the resolver itself only reads them, so a hoisted descriptor that is never changed after creation is safe to reuse.
290
+ - Sibling props still resolve concurrently, as in 2.x, and the output key order and metadata order are deterministic (insertion order).
291
+ - Errors thrown by the `onRescue` hook (or a rejected promise it returns) are logged with `console.error` and never propagated.
163
292
 
164
293
  ## Partial reloads
165
294
 
@@ -537,6 +666,7 @@ For typed page props on the client side, generic `res.inertia<TProps>("Component
537
666
  - The default HTML shell is intentionally minimal. Real apps should provide a `rootView`.
538
667
  - This adapter does not bundle a session implementation — bring your own (`express-session`, `cookie-session`, etc.).
539
668
  - Optimistic-update / `useHttp` server-side helpers are intentionally minimal here; the protocol primitives (`mergeProps`, `deepMergeProps`, `matchPropsOn`, `deferredProps`, `clearHistory`, `encryptHistory`) are exposed.
669
+ - Behaviour that differs from inertia-laravel or from 2.x is listed on the docs site's [Caveats](https://blacklizard.github.io/inertia-express/guide/caveats) page.
540
670
 
541
671
  ## License
542
672