Skip to content

feat(inertia): add infinite scroll with scroll()#1934

Merged
yusukebe merged 3 commits into
honojs:mainfrom
ashunar0:feat/inertia-infinite-scroll
Jun 13, 2026
Merged

feat(inertia): add infinite scroll with scroll()#1934
yusukebe merged 3 commits into
honojs:mainfrom
ashunar0:feat/inertia-infinite-scroll

Conversation

@ashunar0

@ashunar0 ashunar0 commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Final step of the @hono/inertia v3 protocol breakdown: ④ Infinite scroll, built on top of the merge protocol from #1932.

Adds a scroll() helper that wraps a paginated page payload with the metadata Inertia's <InfiniteScroll> adapter needs to keep loading more items as the user scrolls. The wrapped value travels as-is; the renderer emits a new page.scrollProps map with the previous/next page cursor and opts the prop into the merge protocol so each incoming page is appended (or prepended) to the cached array instead of replacing it.

import { inertia, scroll } from '@hono/inertia'

app.use(inertia())

app.get('/users', async (c) => {
  const currentPage = Number(c.req.query('users_page') ?? 1)
  return c.render('Users/Index', {
    users: scroll({
      data: await db.users.page(currentPage),
      currentPage,
      lastPage: 10,
      pageName: 'users_page',
      matchOn: 'id',
    }),
  })
})

Mirrors Inertia::scroll(...) in inertia-laravel 3.x.

Protocol behaviour

Visit kind Behaviour
Any response with a scroll() prop scrollProps[key] = { previousPage, nextPage, currentPage, pageName } emitted and mergeProps.push(key) by default
Request with X-Inertia-Infinite-Scroll-Merge-Intent: prepend prependProps.push(key) instead of mergeProps (the <InfiniteScroll> adapter sends this when scrolling backwards)
matchOn passed (string or array) One "<key>.<field>" entry per field appended to page.matchPropsOn for client-side dedupe; omitted entirely when matchOn is not passed
Scroll-marked prop excluded by only / except Stripped before any metadata is recorded (no leak in scrollProps, mergeProps, or matchPropsOn)
Initial / partial / JSON request Wrapped value is unwrapped and sent as a plain prop

previousPage is currentPage - 1 (or null on the first page); nextPage is currentPage + 1 (or null on the last page).

Implementation

  • scroll<T>({ data, currentPage, lastPage, pageName, matchOn? }): T[] returns an opaque marker (Symbol.for('@hono/inertia/scroll')) that the renderer unwraps — same pattern as defer() (feat(inertia): add deferred props with defer() #1911) and merge() (feat(inertia): add merge props with merge(), prepend(), and deepMerge() #1932). The return type is T[] so call sites and TypedResponse stay transparent.
  • PageObject gains one optional field, scrollProps?: Record<string, ScrollDescriptor>, where ScrollDescriptor = { previousPage, nextPage, currentPage, pageName }. matchOn is intentionally not part of the descriptor — it travels only via page.matchPropsOn, matching Inertia\ScrollProp::metadata() in the Laravel adapter.
  • Default merge direction is append; the client switches it by sending X-Inertia-Infinite-Scroll-Merge-Intent: prepend. Unknown header values fall back to append. Mirrors ScrollProp::configureMergeIntent in inertia-laravel.
  • matchOn accepts a string or string array, optional with no default, matching Inertia::merge(matchOn:) and the existing merge() helper in this package.

Scope intentionally kept small (single PR per protocol feature, like #1904 / #1911 / #1932):

  • ✅ Top-level scroll() marker
  • ✅ Single string and array matchOn
  • ✅ Default append + prepend via the merge-intent header
  • ✅ Coexistence with merge() / prepend() / deepMerge() / defer() in the same response
  • ❌ Laravel paginator auto-extraction — Hono has no equivalent paginator object, so data / currentPage / lastPage / pageName are passed explicitly
  • ❌ Nested / dot-path scroll markers — separate PR (would need to land alongside dot-path partial reloads, same constraint as feat(inertia): add merge props with merge(), prepend(), and deepMerge() #1932)

Test plan

  • `yarn workspace @hono/inertia test` (`56 passed`, 16 new tests covering paging metadata, `previousPage` / `nextPage` boundaries, default-append direction, `prepend` via header, single / array `matchOn`, no-`matchOn` omission, partial-reload preservation, `only` / `except` guard, multi-`scroll()` in one response, coexistence with `merge()` / `prepend()`, HTML embed, raw JSON request, unknown header value fallback)
  • `eslint packages/inertia` clean
  • `prettier --check` clean
  • `tsc -b` clean
  • `tsdown` build success

@changeset-bot

changeset-bot Bot commented Jun 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 36a2b7b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@hono/inertia Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@yusukebe yusukebe left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@yusukebe

Copy link
Copy Markdown
Member

@ashunar0

I didn't know Inertia.js could do something like scroll(). Ineresting! Thanks!

@yusukebe yusukebe merged commit 35682a7 into honojs:main Jun 13, 2026
5 of 6 checks passed
@github-actions github-actions Bot mentioned this pull request Jun 13, 2026
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