feat(inertia): add always() for props that bypass partial reload filtering by ashunar0 · Pull Request #2125 · honojs/middleware · GitHub
Skip to content

feat(inertia): add always() for props that bypass partial reload filtering - #2125

Open
ashunar0 wants to merge 1 commit into
honojs:mainfrom
ashunar0:feat/inertia-always
Open

ashunar0 wants to merge 1 commit into
honojs:mainfrom
ashunar0:feat/inertia-always

Conversation

@ashunar0

@ashunar0 ashunar0 commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Overview

Adds always(), a prop wrapper that bypasses partial reload filtering. This is item ① from #2110, and the foundation the shared data work (②) builds on.

Motivation

Partial reloads narrow the response down to the props listed in X-Inertia-Partial-Data (or everything except the ones in X-Inertia-Partial-Except). Cross-cutting props — validation errors, flash messages, the authenticated user — are still needed by the page even when the client asked for a single prop, and there is currently no way to express that.

app.get('/users', (c) =>
  c.render('Users/Index', {
    users: () => db.users.all(),
    errors: always(getErrors(c)), // present even on a partial reload of `users`
  })
)

Parity with inertia-laravel

AlwaysProp short circuits on the first condition of shouldIncludeInPartialResponse, so it bypasses both the only and the except header. This PR matches that.

if (! $this->isPartial || $prop instanceof AlwaysProp || $parentWasResolved) {
    return true;
}

There is no always entry in buildMetadata(), so nothing is added to the page object.

Implementation

Same marker approach as defer() / merge() / scroll(). The renderer unwraps the marker at the top of the prop loop, skips the filter, and lets the inner value fall through to the existing resolution path. As a result always(() => ...) still resolves lazily and always(merge(...)) still emits its merge metadata. Wrapping a plain value keeps the synchronous fast path intact.

always(defer(...)) is not supported: AlwaysProp is not Deferrable in inertia-laravel either, and "always present on the initial response" and "never present on the initial response" cannot both hold. This is documented in the README.

Related

Item ① of #2110. Item ② (share) is being worked on by @nkfr26 in #2124 — shared data needs this to behave correctly under partial reloads, which is exactly why Laravel wraps errors in always().

The author should do the following, if applicable

  • Add tests
  • Run tests
  • pnpm changeset at the top of this repo and push the changeset
  • Follow the contribution guide

@changeset-bot

changeset-bot Bot commented Sep 5, 2026

Copy link
Copy Markdown

@ashunar0 ashunar0 mentioned this pull request Sep 5, 2026
4 tasks done
@nkfr26

nkfr26 commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

For direct compositions such as always(merge(...)) and always(defer(...)), the current behavior appears to differ from inertia-laravel 3.x.

Case This PR inertia-laravel
always(merge(...)) always is unwrapped first, so the inner merge marker is processed and merge metadata is emitted The outer AlwaysProp is used for filtering and metadata collection. Resolving it also resolves the inner MergeProp to its value, so merge metadata is not emitted
always(defer(...)) always is unwrapped first, so the inner defer marker is processed and the prop is deferred on the initial response Initial exclusion sees the outer AlwaysProp, so the prop is not excluded. Resolving the AlwaysProp also resolves the inner DeferProp, so the value is included in the initial response

I noticed this while looking at these parts of the PR description:

always(merge(...)) still emits its merge metadata.

always(defer(...)) is not supported: AlwaysProp is not Deferrable in inertia-laravel either.

In this PR, const value = forced ? raw.data : raw unwraps always before the existing prop-type checks, so the inner marker gets its normal behavior.

In inertia-laravel, the outer AlwaysProp remains the prop considered for partial filtering, initial exclusion, and metadata collection before resolution. Since resolving an AlwaysProp also resolves callable inner prop objects, direct compositions end up behaving differently. The same applies to always(scroll(...)).

Is this wrapper-composition behavior intentional? I'm wondering whether Hono wants to define these semantics independently, or whether direct compositions should behave closer to inertia-laravel.

If supporting nested prop-marker composition is out of scope for this PR, another option might be to reject it explicitly instead of implicitly applying the inner prop type's behavior.

For example:

export const always = <T>(data: T): T => {
  if (isDeferred(data) || isMerge(data) || isScroll(data)) {
    throw new TypeError('always() cannot wrap another prop type')
  }

  const marker: AlwaysProp<T> = {
    [ALWAYS_MARKER]: true,
    data,
  }
  return marker as unknown as T
}

Since always(defer(...)) is already documented as unsupported, explicitly rejecting nested prop markers may make the API behavior clearer and avoid introducing composition semantics that differ from inertia-laravel.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants