Release 5.0.0-next.1
Pre-releasev5.0.0-next.1
Second pre-release on the road to v5. This round completes most of the remaining Inertia v3 protocol surface on the server adapter: once props, keyed and directional merges, infinite scroll, rescued deferred props, first-class flash messages, shared props tracking, and multiple validation errors per field.
Features
Once props
Once props are computed on the server, cached by the client across visits, and skipped on later standard visits when the client reports a fresh cached value via the X-Inertia-Except-Once-Props header. Partial reloads always resolve an explicitly requested once prop (matching inertia-laravel).
router.get('/dashboard', ({ inertia }) => {
return inertia.render('dashboard', {
/**
* Computed once, then served from the client cache forever
*/
lookups: inertia.once(() => loadLookupTables()),
/**
* Relative expiry — recomputed after two hours
*/
countries: inertia.once(() => fetchCountries(), { expiresIn: '2h' }),
/**
* Custom cache key shared across pages, and `fresh` to force
* resolution even when the client holds a cached value
*/
plans: inertia.once(() => fetchPlans(), { key: 'globalPlans', fresh: true }),
})
}).once() is also chainable on defer, optional, merge, and deepMerge, composing with all of them:
return inertia.render('dashboard', {
stats: inertia.defer(() => computeStats()).once(),
audit: inertia.optional(() => loadAuditLog()).once(),
feed: inertia.merge(() => getFeed()).once({ expiresIn: '15m' }),
})Keyed and directional merges
merge() and deepMerge() gain chainable prepend(), append(), and matchOn() methods. The page object emits the v3 prependProps and matchPropsOn fields, and the X-Inertia-Reset request header is honoured — reset props are emitted unlabeled so the client replaces instead of merging.
router.get('/chat', ({ inertia }) => {
return inertia.render('chat', {
/**
* Prepend new entries before the existing client-side list
* (e.g. loading older messages at the top)
*/
messages: inertia.merge(() => getOlderMessages()).prepend(),
/**
* Keyed merge — the client dedupes existing entries by `id`
* instead of blindly appending
*/
users: inertia.merge(() => getUsers()).matchOn('id'),
/**
* Both compose, and matchOn supports nested paths on deepMerge
*/
feed: inertia.merge(() => getFeed()).prepend().matchOn('id'),
settings: inertia.deepMerge(() => getSettings()).matchOn('list.id'),
})
})Infinite scroll
New inertia.scroll() for paginated props the client continuously extends as the user scrolls, built on the keyed/directional merge primitive. The scroll cursor is auto-derived from a transformer paginator's metadata:
router.get('/users', ({ inertia, request }) => {
const paginator = request.paginator()
return inertia.render('users/index', {
users: inertia.scroll(() => UserTransformer.paginate(rows, paginator.getMeta())),
})
})For cursor-based or custom pagination sources, supply a scrollProps provider callback as the second argument:
return inertia.render('posts/index', {
posts: inertia.scroll(
() => ({ data: posts, meta: { cursor: 'abc', next: 'def' } }),
(value) => ({
pageName: 'cursor',
currentPage: value.meta.cursor,
nextPage: value.meta.next,
previousPage: null,
})
),
})Chain .deferred() to exclude the first page from the initial load, and .matchOn() for keyed dedup:
return inertia.render('users/index', {
users: inertia
.scroll(() => UserTransformer.paginate(rows, paginator.getMeta()))
.deferred()
.matchOn('id'),
})Type safety is end-to-end via the Scroll<Item> client marker — declaring a prop as Scroll<User> makes AsPageProps require inertia.scroll() for it, and .deferred() is only accepted on optional props:
type UsersPageProps = {
users: Scroll<User>
// users?: Scroll<User> ← required when using .deferred()
}The adapter reads the X-Inertia-Infinite-Scroll-Merge-Intent header, emits scrollProps plus dotted <key>.data merge labels, and resets via X-Inertia-Reset.
Rescued deferred props
Deferred props can opt into graceful failure with { rescue: true }. When resolution throws on a partial reload, the prop is omitted from the response (never null), its path is reported in the top-level rescuedProps array, and the client's <Deferred> rescue slot renders — the response itself never errors.
router.get('/dashboard', ({ inertia }) => {
return inertia.render('dashboard', {
stats: inertia.defer(() => computeExpensiveStats(), { rescue: true }),
/**
* The second argument still accepts a group name, and rescue
* composes with .merge() and .once()
*/
activity: inertia.defer(() => getActivity(), 'sidebar'),
feed: inertia.defer(() => getFeed(), { rescue: true }).merge().once(),
})
})Errors are surfaced out of band through an app-registrable static listener, defaulting to ctx.logger.error:
import { Inertia } from '@adonisjs/inertia'
Inertia.onRescue((error, { prop, ctx }) => {
ctx.logger.error({ err: error }, `deferred prop "${prop}" failed`)
})First-class flash messages
Adopts Inertia's top-level flash page field via an optional flash() hook on the middleware, mirroring share(). The hook's return is emitted as a sibling of props — not merged into them — and the field is omitted entirely when no flash() hook is defined, leaving the default wire format unchanged.
// app/middleware/inertia_middleware.ts
export default class InertiaMiddleware extends BaseInertiaMiddleware {
flash(ctx: HttpContext) {
return ctx.session.flashMessages.all()
}
}The hook is the single inferable source for client typing via the new InferFlashData helper, bridged into @inertiajs/core's flashDataType so page.flash, the onFlash callback, and router.flash() are typed end-to-end:
// inertia/app/app.ts
import type InertiaMiddleware from '#middleware/inertia_middleware'
import type { InferFlashData } from '@adonisjs/inertia/types'
declare module '@inertiajs/core' {
interface InertiaConfig {
flashDataType: InferFlashData<InertiaMiddleware>
}
}Shared props tracking
The page object now emits the v3 sharedProps field listing the top-level keys registered via share(), so the client can carry shared props over during instant visits.
inertia
.share({ currentUser: user })
.share({ permissions: () => loadPermissions() })
// Page object now includes:
// { component, props, url, version, sharedProps: ['currentUser', 'permissions'] }Following inertia-laravel, registered keys are reported as-is — a shared prop skipped as deferred/optional or filtered out by a partial reload is still listed, and page-prop overrides keep the key. The field is omitted when no shared keys exist, so the v2 client is unaffected.
Multiple validation errors per field
getValidationErrors on the middleware accepts an opt-in { allMessages: true } mode that emits every field as a uniform string[] (single-message fields as one-element arrays), including under the error-bag key. The default first-message behaviour is unchanged, with typed overloads so each mode returns its precise value type.
// Default: first message per field
const errors = middleware.getValidationErrors(ctx)
// { email: 'Invalid email' }
// Opt-in: every message per field, as string[]
const allErrors = middleware.getValidationErrors(ctx, { allMessages: true })
// { email: ['Invalid email', 'Email already taken'], name: ['Name is required'] }Chores
- Updated dependencies
- New ADRs 0015 and 0018–0024 documenting the designs above, plus ast-grep lint rules
Full changelog: v5.0.0-next.0...v5.0.0-next.1
Commits
- add first-class flash messages (d66bca8)
- add infinite scroll (38cbcac)
- add keyed and directional merges (9a07b4b)
- add multiple errors per field (f0e357a)
- add rescued deferred props (cc66812)
- add shared props tracking (6c69deb)
- add support for once props (c6d5104)
Full Changelog: v5.0.0-next.0...v5.0.0-next.1