Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 5 additions & 17 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 23 additions & 2 deletions packages/inertia-sails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ module.exports.inertia = {
<%- shipwright.styles() %>
</head>
<body>
<div id="app" data-page="<%- JSON.stringify(page) %>"></div>
<div id="app"></div>
<script type="application/json" data-page="app">
<%- JSON.stringify(page).replace(/</g, '\\u003c') %>
</script>
<%- shipwright.scripts() %>
</body>
</html>
Expand Down Expand Up @@ -199,13 +202,29 @@ Merge with existing client-side data (useful for infinite scroll):
// Shallow merge
messages: sails.inertia.merge(() => newMessages)

// Prepend new items instead of appending
notifications: sails.inertia.merge(() => newNotifications).prepend()

// Merge a nested array inside a paginated object
users: sails.inertia.merge(() => paginatedUsers).append('data')

// Match existing items by ID when merging
users: sails.inertia
.merge(() => paginatedUsers)
.append('data', {
matchOn: 'id'
})

// Deep merge (nested objects)
settings: sails.inertia.deepMerge(() => updatedSettings)

// Deep merge with item matching
chat: sails.inertia.deepMerge(() => chatState).matchOn('messages.id')
```

### Infinite Scroll

Paginate data with automatic merge behavior. Works with Inertia.js v2's `<InfiniteScroll>` component:
Paginate data with automatic merge behavior. Works with Inertia's `<InfiniteScroll>` component:

```js
// Controller
Expand Down Expand Up @@ -244,6 +263,8 @@ defineProps({ invoices: Object })
</template>
```

`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.

### History Encryption

Encrypt sensitive data in browser history:
Expand Down
33 changes: 25 additions & 8 deletions packages/inertia-sails/lib/helpers/build-page-object.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,21 @@ const resolveMergeProps = require('../props/resolve-merge-props')
const { resolveOncePropsMetadata } = require('../props/resolve-once-props')
const resolvePageProps = require('../props/resolve-page-props')
const resolveScrollProps = require('../props/resolve-scroll-props')
const resolveAssetVersion = require('./resolve-asset-version')

/**
* @typedef {Object} InertiaPageObject
* @property {string} component - The component name to render
* @property {string} url - The current URL
* @property {string|number} version - Asset version for cache busting
* @property {Object.<string, *>} props - Resolved page props
* @property {boolean} clearHistory - Whether to clear browser history
* @property {boolean} encryptHistory - Whether to encrypt history state
* @property {boolean} [clearHistory] - Whether to clear browser history
* @property {boolean} [encryptHistory] - Whether to encrypt history state
* @property {string[]} [sharedProps] - Shared prop keys included in this response
* @property {string[]} [mergeProps] - Props that should be merged on client
* @property {string[]} [prependProps] - Props that should be prepended on client
* @property {string[]} [deepMergeProps] - Props that should be deep merged
* @property {string[]} [matchPropsOn] - Prop paths to use for matching merge items
* @property {Object.<string, string[]>} [deferredProps] - Deferred props by group
* @property {Object.<string, *>} [onceProps] - Once-prop metadata
* @property {Object.<string, *>} [scrollProps] - Scroll props for InfiniteScroll component
Expand All @@ -38,18 +42,21 @@ const resolveScrollProps = require('../props/resolve-scroll-props')
module.exports = async function buildPageObject(req, component, pageProps) {
const sails = req._sails
let url = req.url || req.originalUrl
const assetVersion = sails.config.inertia.version
const currentVersion =
typeof assetVersion === 'function' ? assetVersion() : assetVersion
const currentVersion = resolveAssetVersion(sails)

const sharedProps = sails.inertia.getShared()
const sharedPropKeys = Object.keys(sharedProps)

// Merge props: global shared → request-scoped shared → page-specific
// This ensures user-specific data (from share()) doesn't leak between requests
const allProps = {
...sails.inertia.getShared(), // Merges global + request-scoped
...sharedProps, // Merges global + request-scoped
...pageProps
}

const propsToResolve = pickPropsToResolve(req, component, allProps)
const clearHistory = sails.inertia.shouldClearHistory()
const encryptHistory = sails.inertia.shouldEncryptHistory()

// Build the page object with all metadata
// Use request-scoped history settings (prevents race conditions)
Expand All @@ -58,14 +65,24 @@ module.exports = async function buildPageObject(req, component, pageProps) {
url,
version: currentVersion,
props: await resolvePageProps(propsToResolve),
clearHistory: sails.inertia.shouldClearHistory(),
encryptHistory: sails.inertia.shouldEncryptHistory(),
...resolveMergeProps(req, allProps),
...resolveDeferredProps(req, component, allProps),
...resolveOncePropsMetadata(allProps),
...resolveScrollProps(allProps)
}

if (clearHistory) {
page.clearHistory = true
}

if (encryptHistory) {
page.encryptHistory = true
}

if (sharedPropKeys.length > 0) {
page.sharedProps = sharedPropKeys
}

// Consume flash data from session and add to props
// Flash data is included in props.flash so it's accessible via usePage().props.flash
// Note: Unlike regular props, flash data should NOT be persisted in browser history
Expand Down
5 changes: 4 additions & 1 deletion packages/inertia-sails/lib/helpers/inertia-headers.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const PARTIAL_COMPONENT = 'X-Inertia-Partial-Component'
const LOCATION = 'X-Inertia-Location'
/** @type {string} - Comma-separated list of once-props client already has */
const EXCEPT_ONCE_PROPS = 'X-Inertia-Except-Once-Props'
/** @type {string} - InfiniteScroll merge direction */
const INFINITE_SCROLL_MERGE_INTENT = 'X-Inertia-Infinite-Scroll-Merge-Intent'

module.exports = {
INERTIA,
Expand All @@ -33,5 +35,6 @@ module.exports = {
RESET,
PARTIAL_COMPONENT,
LOCATION,
EXCEPT_ONCE_PROPS
EXCEPT_ONCE_PROPS,
INFINITE_SCROLL_MERGE_INTENT
}
4 changes: 4 additions & 0 deletions packages/inertia-sails/lib/helpers/resolve-asset-version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = function resolveAssetVersion(sails) {
const assetVersion = sails.config.inertia.version
return typeof assetVersion === 'function' ? assetVersion() : assetVersion
}
2 changes: 1 addition & 1 deletion packages/inertia-sails/lib/props/merge-prop.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ module.exports = class MergeProp extends MergeableProp {
/** @type {Function} */
this.callback = callback
/** @type {boolean} */
this.shouldMerge = true
this.merge()
}

/**
Expand Down
76 changes: 76 additions & 0 deletions packages/inertia-sails/lib/props/merge-targets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Utilities for describing how mergeable props should be merged by the client.
*/

function createDefaultMergeOperation() {
return {
direction: 'append',
path: null,
matchOn: null,
isDefault: true
}
}

function normalizeMergeOptions(options) {
return typeof options === 'string' ? { matchOn: options } : options || {}
}

function normalizeMergeTargets(paths, options = {}) {
const normalizedOptions = normalizeMergeOptions(options)

if (paths === null || paths === undefined) {
return [
{
path: null,
matchOn: normalizedOptions.matchOn || null
}
]
}

if (Array.isArray(paths)) {
return paths.map((path) => ({
path: normalizePath(path),
matchOn: resolveTargetMatchOn(path, normalizedOptions)
}))
}

if (typeof paths === 'object') {
return Object.entries(paths).map(([path, matchOn]) => ({
path: normalizePath(path),
matchOn
}))
}

return [
{
path: normalizePath(paths),
matchOn: resolveTargetMatchOn(paths, normalizedOptions)
}
]
}

function normalizePath(path) {
return path === '' ? null : path
}

function resolveTargetMatchOn(path, options) {
if (!options.matchOn) return null
if (typeof options.matchOn === 'object') return options.matchOn[path] || null
return options.matchOn
}

function resolvePropPath(key, path) {
return path ? `${key}.${path}` : key
}

function unique(values) {
return [...new Set(values)]
}

module.exports = {
createDefaultMergeOperation,
normalizeMergeOptions,
normalizeMergeTargets,
resolvePropPath,
unique
}
Loading
Loading