inertia-sails 1.3.3 → 1.4.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.
Files changed (41) hide show
  1. package/README.md +55 -2
  2. package/index.js +117 -80
  3. package/lib/handle-bad-request.js +20 -6
  4. package/lib/helpers/build-page-object.js +117 -12
  5. package/lib/helpers/inertia-headers.js +4 -1
  6. package/lib/helpers/is-inertia-partial-request.js +10 -0
  7. package/lib/helpers/is-inertia-request.js +9 -0
  8. package/lib/helpers/request-context.js +49 -8
  9. package/lib/helpers/resolve-asset-version.js +12 -0
  10. package/lib/helpers/resolve-validation-errors.js +12 -5
  11. package/lib/location.js +11 -0
  12. package/lib/middleware/inertia-middleware.js +7 -2
  13. package/lib/props/always-prop.js +6 -2
  14. package/lib/props/defer-prop.js +46 -6
  15. package/lib/props/get-partial-data.js +9 -0
  16. package/lib/props/merge-prop.js +7 -4
  17. package/lib/props/merge-targets.js +114 -0
  18. package/lib/props/mergeable-prop.js +87 -0
  19. package/lib/props/once-prop.js +9 -1
  20. package/lib/props/optional-prop.js +12 -4
  21. package/lib/props/pick-props-to-resolve.js +14 -1
  22. package/lib/props/resolve-deferred-props.js +17 -1
  23. package/lib/props/resolve-except-props.js +11 -0
  24. package/lib/props/resolve-merge-props.js +81 -20
  25. package/lib/props/resolve-once-props.js +17 -7
  26. package/lib/props/resolve-only-props.js +10 -3
  27. package/lib/props/resolve-page-props.js +72 -13
  28. package/lib/props/resolve-scroll-props.js +17 -3
  29. package/lib/props/scroll-prop.js +30 -8
  30. package/lib/render.js +68 -9
  31. package/lib/responses/server-error.js +23 -6
  32. package/lib/types.js +68 -0
  33. package/package.json +3 -2
  34. package/test.js +53 -12
  35. package/tests/helpers/build-page-object.test.js +183 -0
  36. package/tests/helpers/preserve-fragment.test.js +97 -0
  37. package/tests/props/merge-targets.test.js +74 -0
  38. package/tests/props/resolve-merge-props.test.js +151 -0
  39. package/tests/props/resolve-page-props.test.js +70 -0
  40. package/tests/props/resolve-scroll-props.test.js +51 -0
  41. package/tests/render.test.js +197 -0
package/README.md CHANGED
@@ -38,7 +38,10 @@ module.exports.inertia = {
38
38
  <%- shipwright.styles() %>
39
39
  </head>
40
40
  <body>
41
- <div id="app" data-page="<%- JSON.stringify(page) %>"></div>
41
+ <div id="app"></div>
42
+ <script type="application/json" data-page="app">
43
+ <%- JSON.stringify(page).replace(/</g, '\\u003c') %>
44
+ </script>
42
45
  <%- shipwright.scripts() %>
43
46
  </body>
44
47
  </html>
@@ -87,6 +90,19 @@ Return a URL string to redirect:
87
90
  return '/dashboard'
88
91
  ```
89
92
 
93
+ #### Preserving URL fragments
94
+
95
+ When a standard Inertia redirect should carry the current hash to the next
96
+ page, mark the redirect before returning the URL:
97
+
98
+ ```js
99
+ sails.inertia.preserveFragment()
100
+ return '/articles/new-slug'
101
+ ```
102
+
103
+ If the user started from `/articles/old-slug#comments`, the Inertia client can
104
+ carry `#comments` to the redirected page.
105
+
90
106
  ### Sharing Data
91
107
 
92
108
  #### `share(key, value)`
@@ -191,6 +207,25 @@ return {
191
207
  }
192
208
  ```
193
209
 
210
+ Deferred props can also be rescued when a non-critical callback fails:
211
+
212
+ ```js
213
+ return {
214
+ page: 'dashboard',
215
+ props: {
216
+ analytics: sails.inertia
217
+ .defer(async () => {
218
+ return await Analytics.getExpensiveReport()
219
+ })
220
+ .rescue()
221
+ }
222
+ }
223
+ ```
224
+
225
+ When a rescued deferred prop throws, it is omitted from `props` and its key is
226
+ reported in `rescuedProps`, allowing the client `<Deferred>` component to show
227
+ its rescue slot instead of failing the whole deferred response.
228
+
194
229
  ### Merge Props
195
230
 
196
231
  Merge with existing client-side data (useful for infinite scroll):
@@ -199,13 +234,29 @@ Merge with existing client-side data (useful for infinite scroll):
199
234
  // Shallow merge
200
235
  messages: sails.inertia.merge(() => newMessages)
201
236
 
237
+ // Prepend new items instead of appending
238
+ notifications: sails.inertia.merge(() => newNotifications).prepend()
239
+
240
+ // Merge a nested array inside a paginated object
241
+ users: sails.inertia.merge(() => paginatedUsers).append('data')
242
+
243
+ // Match existing items by ID when merging
244
+ users: sails.inertia
245
+ .merge(() => paginatedUsers)
246
+ .append('data', {
247
+ matchOn: 'id'
248
+ })
249
+
202
250
  // Deep merge (nested objects)
203
251
  settings: sails.inertia.deepMerge(() => updatedSettings)
252
+
253
+ // Deep merge with item matching
254
+ chat: sails.inertia.deepMerge(() => chatState).matchOn('messages.id')
204
255
  ```
205
256
 
206
257
  ### Infinite Scroll
207
258
 
208
- Paginate data with automatic merge behavior. Works with Inertia.js v2's `<InfiniteScroll>` component:
259
+ Paginate data with automatic merge behavior. Works with Inertia's `<InfiniteScroll>` component:
209
260
 
210
261
  ```js
211
262
  // Controller
@@ -244,6 +295,8 @@ defineProps({ invoices: Object })
244
295
  </template>
245
296
  ```
246
297
 
298
+ `scroll()` targets the wrapped array for merging, such as `invoices.data`, and follows Inertia's infinite-scroll merge intent header so previous-page requests prepend while next-page requests append.
299
+
247
300
  ### History Encryption
248
301
 
249
302
  Encrypt sensitive data in browser history:
package/index.js CHANGED
@@ -9,32 +9,31 @@
9
9
  */
10
10
 
11
11
  /**
12
- * @typedef {import('express').Request} Request
13
- * @typedef {import('express').Response} Response
14
- */
15
-
16
- /**
17
- * @typedef {Object} InertiaConfig
18
- * @property {string} [rootView='app'] - The root view template to use
19
- * @property {string|number|Function} [version=1] - Asset version for cache busting
20
- * @property {Object} [history] - History encryption settings
21
- * @property {boolean} [history.encrypt=false] - Whether to encrypt history state
22
- */
23
-
24
- /**
25
- * @typedef {Object} InertiaPageProps
26
- * @property {Object.<string, *>} [props] - Page props to pass to the component
27
- */
28
-
29
- /**
12
+ * @typedef {import('./lib/types').InertiaRequest} Request
13
+ * @typedef {import('./lib/types').InertiaResponse} Response
14
+ * @typedef {import('./lib/types').InertiaProps} InertiaProps
15
+ * @typedef {import('./lib/types').SailsLike} SailsLike
16
+ * @typedef {import('./lib/types').PropCallback} PropCallback
17
+ * @typedef {import('./lib/types').BadRequestData} BadRequestData
18
+ * @typedef {(req: Request, res: Response, next: () => any) => any} Middleware
19
+ * @typedef {Record<string, any>} InertiaHook
20
+ * @typedef {{ message?: string, stack?: string, name?: string }} ErrorLike
21
+ *
30
22
  * @typedef {Object} InertiaRenderData
31
23
  * @property {string} page - The component name to render
32
- * @property {Object.<string, *>} [props] - Props to pass to the component
33
- * @property {Object.<string, *>} [locals] - Additional locals for the root EJS template
34
- */
35
-
36
- /**
37
- * @typedef {<T>() => T | Promise<T>} PropCallback
24
+ * @property {InertiaProps} [props] - Props to pass to the component
25
+ * @property {InertiaProps} [locals] - Additional locals for the root EJS template
26
+ *
27
+ * @typedef {Object} DeferOptions
28
+ * @property {boolean} [rescue=false] - Rescue callback failures
29
+ *
30
+ * @typedef {Object} ScrollOptions
31
+ * @property {number} [page=0] - Current page index (0-based)
32
+ * @property {number} [perPage=10] - Items per page
33
+ * @property {number} [total=0] - Total number of items
34
+ * @property {string} [pageName='page'] - Query parameter name for pagination
35
+ * @property {string} [wrapper='data'] - Key to wrap the data in
36
+ * @property {string|null} [matchOn] - Optional field used to match items when merging
38
37
  */
39
38
 
40
39
  const inertia = require('./lib/middleware/inertia-middleware')
@@ -51,7 +50,12 @@ const ScrollProp = require('./lib/props/scroll-prop')
51
50
  const handleBadRequest = require('./lib/handle-bad-request')
52
51
  const handleServerError = require('./lib/responses/server-error')
53
52
 
53
+ /**
54
+ * @param {SailsLike} sails
55
+ * @returns {InertiaHook}
56
+ */
54
57
  module.exports = function defineInertiaHook(sails) {
58
+ /** @type {InertiaHook} */
55
59
  let hook
56
60
  const routesToBindInertiaTo = [
57
61
  'GET r|^((?![^?]*\\/[^?\\/]+\\.[^?\\/]+(\\?.*)?).)*$|',
@@ -66,6 +70,12 @@ module.exports = function defineInertiaHook(sails) {
66
70
  // Using startup timestamp ensures fresh assets on each server restart
67
71
  const startupVersion = Date.now().toString(36)
68
72
 
73
+ /** @type {Middleware} */
74
+ const runWithRequestContext = (req, res, next) => {
75
+ if (requestContext.getContext()) return next()
76
+ requestContext.run(req, res, next)
77
+ }
78
+
69
79
  /**
70
80
  * Get asset version from Shipwright manifest.
71
81
  * Automatically hashes the manifest content for cache busting.
@@ -150,9 +160,7 @@ module.exports = function defineInertiaHook(sails) {
150
160
  }
151
161
  }
152
162
  if (!mw.inertiaContext) {
153
- mw.inertiaContext = function inertiaContext(req, res, next) {
154
- requestContext.run(req, res, next)
155
- }
163
+ mw.inertiaContext = runWithRequestContext
156
164
  }
157
165
  }
158
166
  },
@@ -182,28 +190,12 @@ module.exports = function defineInertiaHook(sails) {
182
190
  before: {
183
191
  'GET /*': {
184
192
  skipAssets: true,
185
- fn: (req, res, next) => {
186
- // Skip if context already set up by HTTP middleware
187
- if (requestContext.getContext()) return next()
188
- requestContext.run(req, res, next)
189
- }
190
- },
191
- 'POST /*': (req, res, next) => {
192
- if (requestContext.getContext()) return next()
193
- requestContext.run(req, res, next)
194
- },
195
- 'PUT /*': (req, res, next) => {
196
- if (requestContext.getContext()) return next()
197
- requestContext.run(req, res, next)
193
+ fn: runWithRequestContext
198
194
  },
199
- 'PATCH /*': (req, res, next) => {
200
- if (requestContext.getContext()) return next()
201
- requestContext.run(req, res, next)
202
- },
203
- 'DELETE /*': (req, res, next) => {
204
- if (requestContext.getContext()) return next()
205
- requestContext.run(req, res, next)
206
- }
195
+ 'POST /*': runWithRequestContext,
196
+ 'PUT /*': runWithRequestContext,
197
+ 'PATCH /*': runWithRequestContext,
198
+ 'DELETE /*': runWithRequestContext
207
199
  }
208
200
  },
209
201
 
@@ -325,7 +317,7 @@ module.exports = function defineInertiaHook(sails) {
325
317
  * Create an optional prop
326
318
  * This allows you to define properties that are only evaluated when accessed.
327
319
  * @docs https://docs.sailscasts.com/boring-stack/partial-reloads#lazy-data-evaluation
328
- * @param {Function} callback - The callback function to execute
320
+ * @param {PropCallback} callback - The callback function to execute
329
321
  * @returns {OptionalProp} - The optional prop
330
322
  */
331
323
  optional(callback) {
@@ -336,7 +328,7 @@ module.exports = function defineInertiaHook(sails) {
336
328
  * Create a mergeable prop
337
329
  * This allows you to merge multiple props together.
338
330
  * @docs https://docs.sailscasts.com/boring-stack/merging-props
339
- * @param {Function} callback - The callback function to execute
331
+ * @param {PropCallback} callback - The callback function to execute
340
332
  * @returns {MergeProp} - The mergeable prop
341
333
  */
342
334
  merge(callback) {
@@ -347,7 +339,7 @@ module.exports = function defineInertiaHook(sails) {
347
339
  * Create an always prop
348
340
  * Always props are resolved on every request, whether partial or not.
349
341
  * @docs https://docs.sailscasts.com/boring-stack/partial-reloads#lazy-data-evaluation
350
- * @param {Function} callback - The callback function
342
+ * @param {PropCallback} callback - The callback function
351
343
  * @returns {AlwaysProp} - The always prop
352
344
  */
353
345
  always(callback) {
@@ -357,12 +349,13 @@ module.exports = function defineInertiaHook(sails) {
357
349
  * Create a deferred prop
358
350
  * This allows you to load certain page data after the initial render.
359
351
  * @docs https://docs.sailscasts.com/boring-stack/deferred-props
360
- * @param {Function} cb - The callback function to execute
361
- * @param {string} group - The group name
352
+ * @param {PropCallback} cb - The callback function to execute
353
+ * @param {string|DeferOptions} group - The group name, or options when no group is needed
354
+ * @param {DeferOptions} options - Deferred prop options
362
355
  * @returns {DeferProp} - The deferred prop
363
356
  */
364
- defer(cb, group = 'default') {
365
- return new DeferProp(cb, group)
357
+ defer(cb, group = 'default', options = {}) {
358
+ return new DeferProp(cb, group, options)
366
359
  },
367
360
 
368
361
  /**
@@ -371,7 +364,7 @@ module.exports = function defineInertiaHook(sails) {
371
364
  * The client tracks which props it has via X-Inertia-Except-Once-Props header.
372
365
  * Useful for expensive computations that don't change often.
373
366
  * @docs https://docs.sailscasts.com/boring-stack/once-props
374
- * @param {Function} callback - The callback function to execute
367
+ * @param {PropCallback} callback - The callback function to execute
375
368
  * @returns {OnceProp} - The once prop
376
369
  * @example
377
370
  * // Basic usage
@@ -394,7 +387,7 @@ module.exports = function defineInertiaHook(sails) {
394
387
  * Combines share() and once() - the prop is shared and only resolved once.
395
388
  * @docs https://docs.sailscasts.com/boring-stack/once-props#share-once
396
389
  * @param {string} key - The key of the property
397
- * @param {Function} callback - The callback function to execute
390
+ * @param {PropCallback} callback - The callback function to execute
398
391
  * @returns {OnceProp} - The once prop (for chaining)
399
392
  * @example
400
393
  * // In a policy or middleware
@@ -445,7 +438,7 @@ module.exports = function defineInertiaHook(sails) {
445
438
  * This prevents "phantom" toasts/notifications when users navigate back.
446
439
  * Flash data is stored in the session so it persists across redirects.
447
440
  * @docs https://docs.sailscasts.com/boring-stack/flash
448
- * @param {string|Object} key - The key or an object of key-value pairs
441
+ * @param {string|Record<string, any>} key - The key or an object of key-value pairs
449
442
  * @param {*} [value] - The value (if key is a string)
450
443
  * @returns {Object} - The hook instance for chaining
451
444
  * @example
@@ -469,7 +462,8 @@ module.exports = function defineInertiaHook(sails) {
469
462
  if (typeof key === 'object' && key !== null) {
470
463
  req.session._inertiaFlash = { ...req.session._inertiaFlash, ...key }
471
464
  } else {
472
- req.session._inertiaFlash[key] = value
465
+ const flashKey = /** @type {string} */ (key)
466
+ req.session._inertiaFlash[flashKey] = value
473
467
  }
474
468
  return this
475
469
  },
@@ -486,8 +480,8 @@ module.exports = function defineInertiaHook(sails) {
486
480
  /**
487
481
  * Consume and clear flash data from the session.
488
482
  * Called internally by build-page-object after adding to response.
489
- * @param {Object} req - The request object
490
- * @returns {Object} - The flash data that was consumed
483
+ * @param {Request} req - The request object
484
+ * @returns {InertiaProps} - The flash data that was consumed
491
485
  */
492
486
  consumeFlash(req) {
493
487
  const flash = req?.session?._inertiaFlash || {}
@@ -501,7 +495,7 @@ module.exports = function defineInertiaHook(sails) {
501
495
  * Create a deep merge prop
502
496
  * Like merge(), but recursively merges nested objects instead of replacing them.
503
497
  * @docs https://docs.sailscasts.com/boring-stack/merging-props#deep-merge
504
- * @param {Function} callback - The callback function to execute
498
+ * @param {PropCallback} callback - The callback function to execute
505
499
  * @returns {MergeProp} - The mergeable prop with deep merge enabled
506
500
  * @example
507
501
  * // Deep merge nested user preferences
@@ -515,9 +509,9 @@ module.exports = function defineInertiaHook(sails) {
515
509
 
516
510
  /**
517
511
  * Render the response
518
- * @param {Object} req - The request object
519
- * @param {Object} res - The response object
520
- * @param {Object} data - The data to render
512
+ * @param {Request} req - The request object
513
+ * @param {Response} res - The response object
514
+ * @param {InertiaRenderData} data - The data to render
521
515
  * @returns {*} - The rendered response
522
516
  */
523
517
  render(req, res, data) {
@@ -527,8 +521,8 @@ module.exports = function defineInertiaHook(sails) {
527
521
  * Handle Inertia redirects (external URLs or non-Inertia pages)
528
522
  * Forces a full page visit instead of an Inertia XHR request.
529
523
  * See https://docs.sailscasts.com/boring-stack/redirects
530
- * @param {Object} req - The request object
531
- * @param {Object} res - The response object
524
+ * @param {Request} req - The request object
525
+ * @param {Response} res - The response object
532
526
  * @param {string} url - The URL to redirect to
533
527
  * @returns {Object} - The response object with the redirect
534
528
  */
@@ -585,12 +579,60 @@ module.exports = function defineInertiaHook(sails) {
585
579
  return requestContext.getClearHistory()
586
580
  },
587
581
 
582
+ /**
583
+ * Preserve the current URL fragment across a standard Inertia redirect.
584
+ * The flag is stored in the session so it survives the redirect request,
585
+ * then it is consumed by the next Inertia page response.
586
+ *
587
+ * @docs https://docs.sailscasts.com/boring-stack/redirects#preserving-fragments
588
+ * @param {boolean} preserve - Whether to preserve the URL fragment
589
+ * @returns {Object} - The hook instance for chaining
590
+ * @example
591
+ * sails.inertia.preserveFragment()
592
+ * return '/article/new-slug'
593
+ */
594
+ preserveFragment(preserve = true) {
595
+ const context = requestContext.getContext()
596
+ const req = requestContext.getRequest()
597
+
598
+ if (context) {
599
+ requestContext.setPreserveFragment(preserve)
600
+ }
601
+
602
+ if (req?.session) {
603
+ if (preserve) {
604
+ req.session._inertiaPreserveFragment = true
605
+ } else {
606
+ delete req.session._inertiaPreserveFragment
607
+ }
608
+ }
609
+
610
+ return this
611
+ },
612
+
613
+ /**
614
+ * Consume the preserve fragment flag for the current request.
615
+ * @param {Request} req - The request object
616
+ * @returns {boolean} - Whether to preserve the URL fragment
617
+ */
618
+ consumePreserveFragment(req) {
619
+ const preserve =
620
+ requestContext.getPreserveFragment() ||
621
+ Boolean(req?.session?._inertiaPreserveFragment)
622
+
623
+ if (req?.session) {
624
+ delete req.session._inertiaPreserveFragment
625
+ }
626
+
627
+ return preserve
628
+ },
629
+
588
630
  /**
589
631
  * Handle bad request responses for Inertia.js
590
632
  * For Inertia requests with validation errors, redirects back with errors in session.
591
- * @param {Object} req - The request object
592
- * @param {Object} res - The response object
593
- * @param {Object|Error} [optionalData] - Optional error data or Error object
633
+ * @param {Request} req - The request object
634
+ * @param {Response} res - The response object
635
+ * @param {BadRequestData|Error|Record<string, any>} [optionalData] - Optional error data or Error object
594
636
  * @returns {*} - Response (redirect for Inertia, status code for non-Inertia)
595
637
  */
596
638
  handleBadRequest(req, res, optionalData) {
@@ -602,9 +644,9 @@ module.exports = function defineInertiaHook(sails) {
602
644
  * For Inertia requests in development, displays a styled error modal with stack trace.
603
645
  * In production, redirects back with a flash error message.
604
646
  * @docs https://docs.sailscasts.com/boring-stack/error-handling
605
- * @param {Object} req - The request object
606
- * @param {Object} res - The response object
607
- * @param {Object|Error} [error] - Optional error data or Error object
647
+ * @param {Request} req - The request object
648
+ * @param {Response} res - The response object
649
+ * @param {ErrorLike} [error] - Optional error data or Error object
608
650
  * @returns {*} - Response (HTML modal for dev Inertia, redirect for prod)
609
651
  */
610
652
  handleServerError(req, res, error) {
@@ -620,13 +662,8 @@ module.exports = function defineInertiaHook(sails) {
620
662
  * to 1-based for the Inertia client.
621
663
  *
622
664
  * @docs https://docs.sailscasts.com/boring-stack/infinite-scroll
623
- * @param {Function} callback - Callback returning the paginated data array
624
- * @param {Object} [options] - Pagination options
625
- * @param {number} [options.page=0] - Current page index (0-based)
626
- * @param {number} [options.perPage=10] - Items per page
627
- * @param {number} [options.total=0] - Total number of items
628
- * @param {string} [options.pageName='page'] - Query parameter name for pagination
629
- * @param {string} [options.wrapper='data'] - Key to wrap the data in
665
+ * @param {PropCallback} callback - Callback returning the paginated data array
666
+ * @param {ScrollOptions} [options] - Pagination options
630
667
  * @returns {ScrollProp} - The scroll prop
631
668
  * @example
632
669
  * // Basic usage
@@ -1,3 +1,9 @@
1
+ /**
2
+ * @typedef {import('./types').InertiaRequest} InertiaRequest
3
+ * @typedef {import('./types').InertiaResponse} InertiaResponse
4
+ * @typedef {import('./types').BadRequestData} BadRequestData
5
+ */
6
+
1
7
  /**
2
8
  * Handle bad request responses for Inertia.js
3
9
  *
@@ -5,9 +11,9 @@
5
11
  * previous page with errors stored in the session. For non-Inertia requests,
6
12
  * it returns a standard 400 response.
7
13
  *
8
- * @param {Object} req - Express/Sails request object
9
- * @param {Object} res - Express/Sails response object
10
- * @param {Object|Error} [optionalData] - Optional error data or Error object
14
+ * @param {InertiaRequest} req - Express/Sails request object
15
+ * @param {InertiaResponse} res - Express/Sails response object
16
+ * @param {BadRequestData|Error|Record<string, any>} [optionalData] - Optional error data or Error object
11
17
  * @returns {*} - Response (redirect for Inertia, status code for non-Inertia)
12
18
  *
13
19
  * @example
@@ -22,13 +28,21 @@ module.exports = function handleBadRequest(req, res, optionalData) {
22
28
  const statusCodeToSet = 400
23
29
 
24
30
  // Check if it's an Inertia request
25
- if (req.header('X-Inertia')) {
26
- if (optionalData && optionalData.problems) {
31
+ if (req.header?.('X-Inertia')) {
32
+ if (
33
+ optionalData &&
34
+ !(optionalData instanceof Error) &&
35
+ Array.isArray(optionalData.problems)
36
+ ) {
37
+ /** @type {Record<string, string[]>} */
27
38
  const errors = {}
28
39
  optionalData.problems.forEach((problem) => {
29
40
  if (typeof problem === 'object') {
30
41
  Object.keys(problem).forEach((propertyName) => {
31
- const sanitizedProblem = problem[propertyName].replace(/\.$/, '') // Trim trailing dot
42
+ const sanitizedProblem = String(problem[propertyName]).replace(
43
+ /\.$/,
44
+ ''
45
+ ) // Trim trailing dot
32
46
  if (!errors[propertyName]) {
33
47
  errors[propertyName] = [sanitizedProblem]
34
48
  } else {
@@ -4,23 +4,103 @@ const resolveMergeProps = require('../props/resolve-merge-props')
4
4
  const { resolveOncePropsMetadata } = require('../props/resolve-once-props')
5
5
  const resolvePageProps = require('../props/resolve-page-props')
6
6
  const resolveScrollProps = require('../props/resolve-scroll-props')
7
+ const resolveAssetVersion = require('./resolve-asset-version')
8
+
9
+ /**
10
+ * @typedef {Object} InertiaHookApi
11
+ * @property {() => Object.<string, *>} getShared
12
+ * @property {() => boolean} shouldClearHistory
13
+ * @property {() => boolean} shouldEncryptHistory
14
+ * @property {(req: BuildPageObjectRequest) => boolean} consumePreserveFragment
15
+ * @property {(req: BuildPageObjectRequest) => Object.<string, *>} consumeFlash
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} SailsLike
20
+ * @property {Object.<string, *>} config
21
+ * @property {InertiaHookApi} inertia
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} BuildPageObjectRequest
26
+ * @property {string} [url]
27
+ * @property {string} [originalUrl]
28
+ * @property {SailsLike} _sails
29
+ * @property {(header: string) => string|undefined} get
30
+ */
7
31
 
8
32
  /**
9
33
  * @typedef {Object} InertiaPageObject
10
34
  * @property {string} component - The component name to render
11
35
  * @property {string} url - The current URL
12
- * @property {string|number} version - Asset version for cache busting
36
+ * @property {string|number|null} version - Asset version for cache busting
13
37
  * @property {Object.<string, *>} props - Resolved page props
14
- * @property {boolean} clearHistory - Whether to clear browser history
15
- * @property {boolean} encryptHistory - Whether to encrypt history state
38
+ * @property {boolean} [clearHistory] - Whether to clear browser history
39
+ * @property {boolean} [encryptHistory] - Whether to encrypt history state
40
+ * @property {boolean} [preserveFragment] - Whether to preserve URL fragments across redirects
41
+ * @property {string[]} [sharedProps] - Shared prop keys included in this response
16
42
  * @property {string[]} [mergeProps] - Props that should be merged on client
43
+ * @property {string[]} [prependProps] - Props that should be prepended on client
17
44
  * @property {string[]} [deepMergeProps] - Props that should be deep merged
45
+ * @property {string[]} [matchPropsOn] - Prop paths to use for matching merge items
18
46
  * @property {Object.<string, string[]>} [deferredProps] - Deferred props by group
47
+ * @property {string[]} [rescuedProps] - Deferred props rescued after callback failures
19
48
  * @property {Object.<string, *>} [onceProps] - Once-prop metadata
20
49
  * @property {Object.<string, *>} [scrollProps] - Scroll props for InfiniteScroll component
21
50
  * @property {Object.<string, *>} [flash] - Flash data (not persisted in history)
22
51
  */
23
52
 
53
+ /**
54
+ * @typedef {'mergeProps'|'prependProps'|'deepMergeProps'|'matchPropsOn'} PathMetadataKey
55
+ */
56
+
57
+ /**
58
+ * @param {InertiaPageObject} page
59
+ * @param {PathMetadataKey} key
60
+ */
61
+ function removeEmptyArrayMetadata(page, key) {
62
+ if (Array.isArray(page[key]) && page[key].length === 0) {
63
+ delete page[key]
64
+ }
65
+ }
66
+
67
+ /**
68
+ * @param {InertiaPageObject} page
69
+ * @param {string[]} rescuedProps
70
+ */
71
+ function removeRescuedPropMetadata(page, rescuedProps) {
72
+ if (rescuedProps.length === 0) return
73
+
74
+ const rescuedRootProps = new Set(rescuedProps)
75
+ /** @param {string} path */
76
+ const isNotRescuedPath = (path) => !rescuedRootProps.has(path.split('.')[0])
77
+ /** @type {PathMetadataKey[]} */
78
+ const pathMetadataKeys = [
79
+ 'mergeProps',
80
+ 'prependProps',
81
+ 'deepMergeProps',
82
+ 'matchPropsOn'
83
+ ]
84
+
85
+ pathMetadataKeys.forEach((key) => {
86
+ if (Array.isArray(page[key])) {
87
+ page[key] = page[key].filter(isNotRescuedPath)
88
+ removeEmptyArrayMetadata(page, key)
89
+ }
90
+ })
91
+
92
+ const scrollProps = page.scrollProps
93
+ if (scrollProps) {
94
+ rescuedProps.forEach((key) => {
95
+ delete scrollProps[key]
96
+ })
97
+
98
+ if (Object.keys(scrollProps).length === 0) {
99
+ delete page.scrollProps
100
+ }
101
+ }
102
+ }
103
+
24
104
  /**
25
105
  * Build the Inertia page object for a response.
26
106
  *
@@ -30,42 +110,67 @@ const resolveScrollProps = require('../props/resolve-scroll-props')
30
110
  * Uses request-scoped shared props (via AsyncLocalStorage) merged with
31
111
  * global shared props to prevent data leaking between concurrent requests.
32
112
  *
33
- * @param {Object} req - Express/Sails request object
113
+ * @param {BuildPageObjectRequest} req - Express/Sails request object
34
114
  * @param {string} component - The component name to render
35
115
  * @param {Object.<string, *>} pageProps - Props specific to this page
36
116
  * @returns {Promise<InertiaPageObject>} - The complete page object
37
117
  */
38
118
  module.exports = async function buildPageObject(req, component, pageProps) {
39
119
  const sails = req._sails
40
- let url = req.url || req.originalUrl
41
- const assetVersion = sails.config.inertia.version
42
- const currentVersion =
43
- typeof assetVersion === 'function' ? assetVersion() : assetVersion
120
+ let url = req.url || req.originalUrl || '/'
121
+ const currentVersion = resolveAssetVersion(sails)
122
+
123
+ const sharedProps = sails.inertia.getShared()
124
+ const sharedPropKeys = Object.keys(sharedProps)
44
125
 
45
126
  // Merge props: global shared → request-scoped shared → page-specific
46
127
  // This ensures user-specific data (from share()) doesn't leak between requests
47
128
  const allProps = {
48
- ...sails.inertia.getShared(), // Merges global + request-scoped
129
+ ...sharedProps, // Merges global + request-scoped
49
130
  ...pageProps
50
131
  }
51
132
 
52
133
  const propsToResolve = pickPropsToResolve(req, component, allProps)
134
+ const clearHistory = sails.inertia.shouldClearHistory()
135
+ const encryptHistory = sails.inertia.shouldEncryptHistory()
136
+ const preserveFragment = sails.inertia.consumePreserveFragment(req)
137
+ const resolvedPageProps = await resolvePageProps.withMetadata(propsToResolve)
53
138
 
54
139
  // Build the page object with all metadata
55
140
  // Use request-scoped history settings (prevents race conditions)
141
+ /** @type {InertiaPageObject} */
56
142
  const page = {
57
143
  component,
58
144
  url,
59
145
  version: currentVersion,
60
- props: await resolvePageProps(propsToResolve),
61
- clearHistory: sails.inertia.shouldClearHistory(),
62
- encryptHistory: sails.inertia.shouldEncryptHistory(),
146
+ props: resolvedPageProps.props,
63
147
  ...resolveMergeProps(req, allProps),
64
148
  ...resolveDeferredProps(req, component, allProps),
65
149
  ...resolveOncePropsMetadata(allProps),
66
150
  ...resolveScrollProps(allProps)
67
151
  }
68
152
 
153
+ if (clearHistory) {
154
+ page.clearHistory = true
155
+ }
156
+
157
+ if (encryptHistory) {
158
+ page.encryptHistory = true
159
+ }
160
+
161
+ if (preserveFragment) {
162
+ page.preserveFragment = true
163
+ }
164
+
165
+ if (resolvedPageProps.rescuedProps.length > 0) {
166
+ page.rescuedProps = resolvedPageProps.rescuedProps
167
+ removeRescuedPropMetadata(page, resolvedPageProps.rescuedProps)
168
+ }
169
+
170
+ if (sharedPropKeys.length > 0) {
171
+ page.sharedProps = sharedPropKeys
172
+ }
173
+
69
174
  // Consume flash data from session and add to props
70
175
  // Flash data is included in props.flash so it's accessible via usePage().props.flash
71
176
  // Note: Unlike regular props, flash data should NOT be persisted in browser history