diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml
index d60c964c..e885474c 100644
--- a/.github/workflows/push.yml
+++ b/.github/workflows/push.yml
@@ -34,12 +34,16 @@ jobs:
fail-fast: false
matrix:
ruby: ['3.0', '3.1', '3.2', '3.3']
- rails: ['6.1', '7.0', '7.1', '7.2', '8.0']
+ rails: ['6.1', '7.0', '7.1', '7.2', '8.0', '8.1']
exclude:
- ruby: '3.0'
rails: '8.0'
- ruby: '3.1'
rails: '8.0'
+ - ruby: '3.0'
+ rails: '8.1'
+ - ruby: '3.1'
+ rails: '8.1'
- ruby: '3.0'
rails: '7.2'
- ruby: '3.1'
@@ -63,9 +67,7 @@ jobs:
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
- # Use the latest version of RubyGems with Ruby 3.0 to avoid:
- # https://bugs.ruby-lang.org/issues/19371
- rubygems: ${{ startsWith(matrix.ruby-version, '3.0') && 'latest' || 'default' }}
+ rubygems: latest
bundler-cache: true
env:
RAILS_VERSION: ${{ matrix.rails }}
diff --git a/Gemfile b/Gemfile
index 191756a6..8e9874e5 100644
--- a/Gemfile
+++ b/Gemfile
@@ -5,7 +5,7 @@ source 'https://rubygems.org'
# Specify your gem's dependencies in inertia_rails.gemspec
gemspec
-version = ENV['RAILS_VERSION'] || '8.0'
+version = ENV.fetch('RAILS_VERSION', '8.1')
gem 'rails', "~> #{version}.0"
gem 'bundler', '~> 2.0'
@@ -17,3 +17,6 @@ gem 'responders'
gem 'rspec-rails', '~> 6.0'
gem 'rubocop', '~> 1.21'
gem 'sqlite3'
+
+gem 'kaminari'
+gem 'pagy'
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index ab5d373f..5bb2b747 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -127,6 +127,7 @@ export default defineConfig({
{ text: 'Prefetching', link: '/guide/prefetching' },
{ text: 'Load when visible', link: '/guide/load-when-visible' },
{ text: 'Merging props', link: '/guide/merging-props' },
+ { text: 'Infinite scroll', link: '/guide/infinite-scroll' },
{ text: 'Remembering state', link: '/guide/remembering-state' },
],
},
diff --git a/docs/.vitepress/theme/components/Opt.vue b/docs/.vitepress/theme/components/Opt.vue
new file mode 100644
index 00000000..d9ba2c47
--- /dev/null
+++ b/docs/.vitepress/theme/components/Opt.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
diff --git a/docs/.vitepress/theme/components/React.vue b/docs/.vitepress/theme/components/React.vue
new file mode 100644
index 00000000..f6a10d0b
--- /dev/null
+++ b/docs/.vitepress/theme/components/React.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/docs/.vitepress/theme/components/Svelte.vue b/docs/.vitepress/theme/components/Svelte.vue
new file mode 100644
index 00000000..deb6d6b6
--- /dev/null
+++ b/docs/.vitepress/theme/components/Svelte.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/docs/.vitepress/theme/components/Svelte4.vue b/docs/.vitepress/theme/components/Svelte4.vue
new file mode 100644
index 00000000..d53fbf3a
--- /dev/null
+++ b/docs/.vitepress/theme/components/Svelte4.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/docs/.vitepress/theme/components/Svelte5.vue b/docs/.vitepress/theme/components/Svelte5.vue
new file mode 100644
index 00000000..6392c90c
--- /dev/null
+++ b/docs/.vitepress/theme/components/Svelte5.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/docs/.vitepress/theme/components/Vue.vue b/docs/.vitepress/theme/components/Vue.vue
new file mode 100644
index 00000000..7fee1075
--- /dev/null
+++ b/docs/.vitepress/theme/components/Vue.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/docs/.vitepress/theme/components/index.ts b/docs/.vitepress/theme/components/index.ts
index cdc0816c..461096cf 100644
--- a/docs/.vitepress/theme/components/index.ts
+++ b/docs/.vitepress/theme/components/index.ts
@@ -1,3 +1,9 @@
import AvailableSince from './AvailableSince.vue'
+import Opt from './Opt.vue'
+import React from './React.vue'
+import Svelte from './Svelte.vue'
+import Svelte4 from './Svelte4.vue'
+import Svelte5 from './Svelte5.vue'
+import Vue from './Vue.vue'
-export { AvailableSince }
+export { AvailableSince, Opt, React, Svelte, Svelte4, Svelte5, Vue }
diff --git a/docs/.vitepress/theme/composables/useTabsSelectedState.ts b/docs/.vitepress/theme/composables/useTabsSelectedState.ts
new file mode 100644
index 00000000..5b526dd3
--- /dev/null
+++ b/docs/.vitepress/theme/composables/useTabsSelectedState.ts
@@ -0,0 +1,78 @@
+import type { InjectionKey, Ref } from 'vue'
+import { computed, inject, onMounted, ref } from 'vue'
+
+type TabsSharedState = {
+ content?: TabsSharedStateContent
+}
+type TabsSharedStateContent = Record
+
+// Use the same injection key as the original vitepress-plugin-tabs
+const injectionKey: InjectionKey =
+ 'vitepress:tabSharedState' as unknown as symbol
+const ls = typeof localStorage !== 'undefined' ? localStorage : null
+const localStorageKey = 'vitepress:tabsSharedState'
+
+const getLocalStorageValue = (): TabsSharedStateContent => {
+ const rawValue = ls?.getItem(localStorageKey)
+ if (rawValue) {
+ try {
+ return JSON.parse(rawValue)
+ } catch {}
+ }
+ return {}
+}
+
+export const useTabsSelectedState = (
+ acceptValues: Ref,
+ sharedStateKey: Ref,
+) => {
+ const sharedState = inject(injectionKey)
+ if (!sharedState) {
+ throw new Error(
+ '[vitepress-plugin-tabs] TabsSharedState should be injected',
+ )
+ }
+
+ onMounted(() => {
+ if (!sharedState.content) {
+ sharedState.content = getLocalStorageValue()
+ }
+ })
+
+ const nonSharedState = ref()
+
+ const selected = computed({
+ get() {
+ const key = sharedStateKey.value
+ const acceptVals = acceptValues.value
+ if (key) {
+ const value = sharedState.content?.[key]
+ if (value && (acceptVals as string[]).includes(value)) {
+ return value as T
+ }
+ } else {
+ const nonSharedStateVal = nonSharedState.value
+ if (nonSharedStateVal) {
+ return nonSharedStateVal
+ }
+ }
+ return acceptVals[0]
+ },
+ set(v) {
+ const key = sharedStateKey.value
+ if (key) {
+ if (sharedState.content) {
+ sharedState.content[key] = v
+ }
+ } else {
+ nonSharedState.value = v
+ }
+ },
+ })
+
+ const select = (newValue: T) => {
+ selected.value = newValue
+ }
+
+ return { selected, select }
+}
diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts
index ab4fb10c..02bcbcba 100644
--- a/docs/.vitepress/theme/index.ts
+++ b/docs/.vitepress/theme/index.ts
@@ -3,7 +3,15 @@ import type { Theme } from 'vitepress'
import { enhanceAppWithTabs } from 'vitepress-plugin-tabs/client'
import DefaultTheme from 'vitepress/theme'
import { h } from 'vue'
-import { AvailableSince } from './components'
+import {
+ AvailableSince,
+ Opt,
+ React,
+ Svelte,
+ Svelte4,
+ Svelte5,
+ Vue,
+} from './components'
import { setupFrameworksTabs } from './frameworksTabs'
import './style.css'
@@ -17,6 +25,12 @@ export default {
enhanceApp({ app, router, siteData }) {
enhanceAppWithTabs(app)
app.component('AvailableSince', AvailableSince)
+ app.component('Opt', Opt)
+ app.component('React', React)
+ app.component('Vue', Vue)
+ app.component('Svelte', Svelte)
+ app.component('Svelte4', Svelte4)
+ app.component('Svelte5', Svelte5)
},
setup() {
setupFrameworksTabs()
diff --git a/docs/guide/code-splitting.md b/docs/guide/code-splitting.md
index b62f32f5..69f8cb87 100644
--- a/docs/guide/code-splitting.md
+++ b/docs/guide/code-splitting.md
@@ -4,7 +4,7 @@ Code splitting breaks apart the various pages of your application into smaller b
While code splitting is helpful for very large projects, it does require extra requests when visiting new pages. Generally speaking, if you're able to use a single bundle, your app is going to feel snappier.
-To enable code splitting you'll need to tweak the resolve callback in your `createInertiaApp()` configuration, and how you do this is different depending on which bundler you're using.
+To enable code splitting, you will need to tweak the `resolve` callback in your `createInertiaApp()` configuration, and how you do this is different depending on which bundler you're using.
## Using Vite
diff --git a/docs/guide/error-handling.md b/docs/guide/error-handling.md
index b8db0c79..c808c214 100644
--- a/docs/guide/error-handling.md
+++ b/docs/guide/error-handling.md
@@ -131,13 +131,13 @@ export default function ErrorPage({ status }) {
```svelte
-
{titles[status]}
+
{title[status]}
{description[status]}
```
diff --git a/docs/guide/forms.md b/docs/guide/forms.md
index 53d8e187..3378abc5 100644
--- a/docs/guide/forms.md
+++ b/docs/guide/forms.md
@@ -30,8 +30,6 @@ import { Form } from '@inertiajs/vue3'
```
-Just like a traditional HTML form, there is no need to attach a `v-model` to your input fields, just give each input a `name` attribute and the `Form` component will handle the data submission for you.
-
== React
```jsx
@@ -46,9 +44,7 @@ export default () => (
)
```
-Just like a traditional HTML form, there is no need to attach an `onChange` handler to your input fields, just give each input a `name` attribute and a `defaultValue` (if applicable) and the `Form` component will handle the data submission for you.
-
-== Svelte 4| Svelte 5
+== Svelte 4|Svelte 5
```svelte
+
+
+ {#each users as user (user.id)}
+
{user.name}
+ {/each}
+
+```
+
+::::
+
+The component uses [intersection observers](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) to detect when users scroll near the end of the content and automatically triggers requests to load the next page. New data is merged with existing content rather than replacing it.
+
+## Loading buffer
+
+You can control how early content begins loading by setting a buffer distance. The buffer specifies how many pixels before the end of the content loading should begin.
+
+:::tabs key:frameworks
+== Vue
+
+```vue
+
+
+
+
+
+```
+
+== React
+
+```jsx
+
+ {/* ... */}
+
+```
+
+== Svelte 4|Svelte 5
+
+```svelte
+
+
+
+```
+
+::::
+
+In the example above, content will start loading 500 pixels before reaching the end of the current content. A larger buffer loads content earlier but potentially loads content that users may never see.
+
+## URL synchronization
+
+The infinite scroll component updates the browser URL's query string (`?page=...`) as users scroll through content. The URL reflects which page has the most visible items on screen, updating in both directions as users scroll up or down. This allows users to bookmark or share links to specific pages. You can disable this behavior to maintain the original page URL.
+
+:::tabs key:frameworks
+== Vue
+
+```vue
+
+
+
+
+
+```
+
+== React
+
+```jsx
+
+ {/* ... */}
+
+```
+
+== Svelte 4|Svelte 5
+
+```svelte
+
+
+
+```
+
+::::
+
+This is useful when infinite scroll is used for secondary content that shouldn't affect the main page URL, such as comments on a blog post or related products on a product page.
+
+## Resetting
+
+When filters or other parameters change, you may need to reset the infinite scroll data to start from the beginning. Without resetting, new results will merge with existing content instead of replacing it.
+
+You can reset data using the `reset` visit option.
+
+:::tabs key:frameworks
+== Vue
+
+```vue
+
+
+
+
+
+
+
+
+
+```
+
+::::
+
+You can also configure the component to automatically switch to manual mode after a certain number of pages using the `:manual-after``manualAfter``manual-after` prop.
+
+:::tabs key:frameworks
+== Vue
+
+```vue
+
+
+
+
+
+```
+
+== React
+
+```jsx
+
+ {/* ... */}
+
+```
+
+== Svelte 4|Svelte 5
+
+```svelte
+
+
+
+```
+
+::::
+
+## Slots
+
+The infinite scroll component provides several slots to customize the loading experience. These slots allow you to display custom loading indicators and create manual load controls. Each slot receives properties that provide loading state information and functions to trigger content loading.
+
+### Default slot
+
+The main content area where you render your data items. This slot receives loading state information.
+
+:::tabs key:frameworks
+== Vue
+
+```vue
+
+
+
+
+
+```
+
+== React
+
+```jsx
+
+ {({ loading, loadingPrevious, loadingNext }) => (
+
{/* Your content with access to loading states */}
+ )}
+
+```
+
+== Svelte 4|Svelte 5
+
+```svelte
+
+
+
+```
+
+::::
+
+### Loading slot
+
+The loading slot is used as a fallback when loading content and no custom `before` or `after` slots are provided. This creates a default loading indicator.
+
+:::tabs key:frameworks
+== Vue
+
+```vue
+
+
+
+ Loading more users...
+
+
+```
+
+== React
+
+```jsx
+ 'Loading more users...'}>
+ {/* Your content */}
+
+```
+
+== Svelte 4|Svelte 5
+
+```svelte
+
+
+
Loading more users...
+
+```
+
+::::
+
+### Previous and next slots
+
+The `previous` and `next` slots are rendered above and below the main content, typically used for manual load controls. These slots receive several properties including loading states, fetch functions, and mode indicators.
+
+:::tabs key:frameworks
+== Vue
+
+```vue
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+== React
+
+```jsx
+import { InfiniteScroll } from '@inertiajs/react'
+
+export default ({ users }) => (
+
+ manualMode &&
+ hasMore && (
+
+ )
+ }
+ next={({ loading, fetch, hasMore, manualMode }) =>
+ manualMode &&
+ hasMore && (
+
+ )
+ }
+ >
+ {users.map((user) => (
+
+
+```
+
+:::
+
+The `loading`, `previous`, and `next` slots receive the following properties:
+
+- `loading` - Whether the slot is currently loading content
+- `loadingPrevious` - Whether previous content is loading
+- `loadingNext` - Whether next content is loading
+- `fetch` - Function to trigger loading for the slot
+- `hasMore` - Whether more content is available for the slot
+- `hasPrevious` - Whether more previous content is available
+- `hasNext` - Whether more next content is available
+- `manualMode` - Whether manual mode is active
+- `autoMode` - Whether automatic loading is active
+
+## Custom element
+
+The `InfiniteScroll` component renders as a `
` element. You may customize this to use any HTML element using the `as` prop.
+
+:::tabs key:frameworks
+== Vue
+
+```vue
+
+
+
+ {/each}
+
+```
+
+::::
+
+## Element targeting
+
+The infinite scroll component automatically tracks content and assigns page numbers to elements for [URL synchronization](#url-synchronization). When your data items are not direct children of the component's root element, you need to specify which element contains the actual data items using the `items-element``itemsElement``items-element` prop.
+
+:::tabs key:frameworks
+== Vue
+
+```vue
+
+
+
+
+
+
Name
+
+
+
+
+
{{ user.name }}
+
+
+
+
+
+```
+
+== React
+
+```jsx
+
+
+
+
+
Name
+
+
+
+ {users.map((user) => (
+
+
{user.name}
+
+ ))}
+
+
+
+```
+
+== Svelte 4|Svelte 5
+
+```svelte
+
+
+
+
Name
+
+
+ {#each users as user (user.id)}
+
+
{user.name}
+
+ {/each}
+
+
+
+```
+
+::::
+
+In this example, the component monitors the `#table-body` element and automatically tags each `
` with a page number as new content loads. This enables proper URL updates based on which page's content is most visible in the viewport.
+
+You can also specify custom trigger elements for loading more content using CSS selectors. This prevents the default trigger elements from being rendered and uses intersection observers on your custom elements instead.
+
+:::tabs key:frameworks
+== Vue
+
+```vue
+
+
+
+
+
+
Name
+
+
+
+
+
{{ user.name }}
+
+
+
+
+
Footer
+
+
+
+
+
+```
+
+== React
+
+```jsx
+
+
+
+
+
Name
+
+
+
+ {users.data.map((user) => (
+
+
{user.name}
+
+ ))}
+
+
+
+
Footer
+
+
+
+
+```
+
+== Svelte 4|Svelte 5
+
+```svelte
+
+
+
+
Name
+
+
+ {#each users.data as user (user.id)}
+
+
{user.name}
+
+ {/each}
+
+
+
Footer
+
+
+
+```
+
+::::
+
+Alternatively, you can use template refs instead of CSS selectors. This avoids adding HTML attributes and provides direct element references.
+
+:::tabs key:frameworks
+== Vue
+
+```vue
+
+
+
+
+
+
+```
+
+::::
+
+## Scroll containers
+
+The infinite scroll component works within any scrollable container, not just the main document. The component automatically adapts to use the custom scroll container for trigger detection and calculations instead of the main document scroll.
+
+:::tabs key:frameworks
+== Vue
+
+```vue
+
+
+
+
+ {{ user.name }}
+
+
+
+
+```
+
+== React
+
+```jsx
+
+
+ {users.data.map((user) => (
+
{user.name}
+ ))}
+
+
+```
+
+== Svelte 4|Svelte 5
+
+```svelte
+
+
+ {#each users.data as user (user.id)}
+
{user.name}
+ {/each}
+
+
+```
+
+::::
+
+### Multiple scroll containers
+
+Sometimes you may need to render multiple infinite scroll components on a single page. However, if both components use the default `page` query parameter for [URL synchronization](#url-synchronization), they will conflict with each other. To resolve this, instruct each paginator to use a custom `page_name`.
+
+:::tabs key:pagination_gems
+
+== Pagy
+
+```ruby
+class DashboardController < ApplicationController
+ include Pagy::Method
+
+ def index
+ pagy_users, users = pagy(:countless, User.all, page_param: :users)
+ pagy_orders, orders = pagy(:countless, Order.all, page_param: :orders)
+
+ render inertia: {
+ users: InertiaRails.scroll(pagy_users) { users.as_json(...) },
+ orders: InertiaRails.scroll(pagy_orders) { orders.as_json(...) }
+ }
+ end
+end
+```
+
+== Kaminari
+
+```ruby
+
+class DashboardController < ApplicationController
+ def index
+ users = User.page(params[:users])
+ orders = Order.page(params[:orders])
+
+ render inertia: {
+ users: InertiaRails.scroll(users, page_name: 'users') { users.as_json(...) },
+ orders: InertiaRails.scroll(orders, page_name: 'orders') { orders.as_json(...) }
+ }
+ end
+end
+```
+
+== Manual
+
+```ruby
+class DashboardController < ApplicationController
+ def index
+ users_meta, users = paginate(User.order(:name), page_name: 'users')
+ orders_meta, orders = paginate(Order.order(:created_at), page_name: 'orders')
+
+ render inertia: {
+ users: InertiaRails.scroll(users_meta) { users.as_json(...) },
+ orders: InertiaRails.scroll(orders_meta) { orders.as_json(...) }
+ }
+ end
+
+ private
+
+ PER_PAGE = 20
+
+ def paginate(scope, page_param: :page)
+ page = [params.fetch(page_param, 1).to_i, 1].max
+
+ records = scope.offset((page - 1) * PER_PAGE).limit(PER_PAGE + 1)
+
+ meta = {
+ page_name: page_param.to_s,
+ previous_page: page > 1 ? page - 1 : nil,
+ next_page: records.length > PER_PAGE ? page + 1 : nil,
+ current_page: page
+ }
+
+ [meta, records.first(PER_PAGE)]
+ end
+end
+```
+
+:::
+
+The `InertiaRails.scroll` method automatically detects the `page_name` from each paginator metadata, allowing both scroll containers to maintain independent pagination state. This results in URLs like `?users=2&orders=3` instead of conflicting `?page=` parameters.
+
+## Programmatic access
+
+When you need to trigger loading actions programmatically, you may use a template ref.
+
+:::tabs key:frameworks
+== Vue
+
+```vue
+
+
+
+
+
+
+
+
+
+```
+
+== React
+
+```jsx
+import { InfiniteScroll } from '@inertiajs/react'
+import { useRef } from 'react'
+
+export default ({ users }) => {
+ const infiniteScrollRef = useRef(null)
+
+ const fetchNext = () => {
+ infiniteScrollRef.current?.fetchNext()
+ }
+
+ return (
+ <>
+
+
+
+ {users.data.map((user) => (
+
+ {/each}
+
+```
+
+::::
+
+The component exposes the following methods:
+
+- `fetchNext()` - Manually fetch the next page
+- `fetchPrevious()` - Manually fetch the previous page
+- `hasNext()` - Whether there is a next page
+- `hasPrevious()` - Whether there is a previous page
+
+## `InertiaRails.scroll` method
+
+The `InertiaRails.scroll` method provides server-side configuration for infinite scrolling. It automatically configures the proper merge behavior so that new data is appended or prepended to existing content instead of replacing it, and normalizes pagination metadata for the frontend component.
+
+```ruby
+# Works with Pagy...
+InertiaRails.scroll(pagy_instance) { records.as_json(...) }
+
+# Works with Kaminari...
+InertiaRails.scroll(kaminari_collection) { kaminari_collection.as_json(...) }
+
+# Works with hash metadata...
+InertiaRails.scroll(metadata_hash) { data.as_json(...) }
+```
+
+If you don't use Pagy or Kaminari, or need custom pagination behavior, you may use the additional options that `scroll()` accepts.
+
+### Hash metadata
+
+When using custom pagination libraries or manual pagination, you can provide pagination metadata as a hash:
+
+```ruby
+class UsersController < ApplicationController
+ def index
+ page = params[:page]&.to_i || 1
+ users = User.offset((page - 1) * 20).limit(21)
+ has_more = users.count > 20
+
+ metadata = {
+ page_name: 'page',
+ current_page: page,
+ previous_page: page > 1 ? page - 1 : nil,
+ next_page: has_more ? page + 1 : nil
+ }
+
+ render inertia: {
+ users: InertiaRails.scroll(metadata) { users.first(20).as_json(...) }
+ }
+ end
+end
+```
+
+The hash must include all required keys: `page_name`, `current_page`, `previous_page`, and `next_page`.
+
+### Custom pagination adapters
+
+If you're using a pagination library that isn't supported out of the box, you can create and register a custom adapter:
+
+```ruby
+class CustomPaginatorAdapter
+ def match?(metadata)
+ metadata.is_a?(CustomPaginator)
+ end
+
+ def call(metadata, **options)
+ {
+ page_name: options[:page_name] || 'page',
+ previous_page: metadata.has_previous? ? metadata.previous_page_number : nil,
+ next_page: metadata.has_next? ? metadata.next_page_number : nil,
+ current_page: metadata.current_page_number
+ }
+ end
+end
+
+# Register the adapter (typically in an initializer)
+InertiaRails::ScrollMetadata.register_adapter(CustomPaginatorAdapter)
+```
+
+Adapters are checked in reverse registration order, so custom adapters registered later will take precedence over built-in adapters.
+
+### Overriding attributes
+
+You can override any of the default attributes by passing a hash of options.
+
+```ruby
+class UsersController < ApplicationController
+ def index
+ users = User.page(params[:page])
+
+ render inertia: {
+ users: InertiaRails.scroll(users, page_name: 'page_number') do
+ users.as_json(...)
+ end
+ }
+ end
+end
+```
+
+### Wrapper option
+
+The `wrapper` option allows you to specify a custom key for nested data structures. This is useful when your data is wrapped in an object with metadata:
+
+```ruby
+class UsersController < ApplicationController
+ def index
+ users = User.page(params[:page])
+
+ render inertia: {
+ users: InertiaRails.scroll(users, wrapper: 'data') do
+ {
+ items: users.as_json(...),
+ metadata: { total: users.total_count }
+ }
+ end
+ }
+ end
+end
+```
+
+This example demonstrates how the `wrapper` option works with nested data structures, ensuring that only the `items` array gets merged during infinite scrolling while preserving the `metadata` object.
diff --git a/docs/guide/links.md b/docs/guide/links.md
index 6c517f2f..f4eabe2c 100644
--- a/docs/guide/links.md
+++ b/docs/guide/links.md
@@ -369,7 +369,7 @@ export default () => (
:::
-For more information on managing scroll position, please consult the documentation on [scroll management](/guide/scroll-management).
+For more information on managing scroll position, check out the documentation on [scroll management](/guide/scroll-management).
## Partial reloads
@@ -414,11 +414,11 @@ export default () => (
:::
-For more information on this topic, please consult the complete documentation on [partial reloads](/guide/partial-reloads.md).
+For more information on this topic, check out the complete documentation on [partial reloads](/guide/partial-reloads.md).
## Active states
-It's often desirable to set an active state for navigation links based on the current page. This can be accomplished when using Inertia by inspecting the `page` object and doing string comparisons against the `page.url` and `page.component` properties.
+It's common to set an active state for navigation links based on the current page. This can be accomplished when using Inertia by inspecting the `page` object and doing string comparisons against the `page.url` and `page.component` properties.
:::tabs key:frameworks
== Vue
diff --git a/docs/guide/load-when-visible.md b/docs/guide/load-when-visible.md
index a2752e97..7c973dd1 100644
--- a/docs/guide/load-when-visible.md
+++ b/docs/guide/load-when-visible.md
@@ -292,7 +292,7 @@ export default () => (
By default, the `WhenVisible` component will only trigger once when the element becomes visible. If you want to always trigger the data loading when the element is visible, you can provide the `always` prop.
-This is useful when you want to load data every time the element becomes visible, such as when the element is at the end of an infinite scroll list and you want to load more data.
+This is useful when you want to load data every time the element becomes visible, such as when the element is at the end of an infinite scroll list and you want to load more data. Alternatively, you can use the [Infinite scroll](/guide/infinite-scroll) component which handles this use case for you.
Note that if the data loading request is already in flight, the component will wait until it is finished to start the next request if the element is still visible in the viewport.
diff --git a/docs/guide/manual-visits.md b/docs/guide/manual-visits.md
index 4bcca6e5..af9d8ee0 100644
--- a/docs/guide/manual-visits.md
+++ b/docs/guide/manual-visits.md
@@ -363,7 +363,7 @@ router.post('/companies', data, {
:::
-For more information on uploading files, please consult the dedicated [file uploads](/guide/file-uploads.md) documentation.
+For more information on uploading files, check out the dedicated [file uploads](/guide/file-uploads.md) documentation.
## Browser history
@@ -468,7 +468,7 @@ router.push({
All the parameters are optional. By default, all passed parameters (except `errorBag`) will be merged with the current page. This means you are responsible for overriding the current page's URL, component, and props.
-If you need access to the current page's props you can pass a function to the props option. This function will receive the current page's props as an argument and should return the new props.
+If you need access to the current page's props, you can pass a function to the props option. This function will receive the current page's props as an argument and should return the new props.
The `errorBag` option allows you to specify which error bag to use when handling validation errors in the `onError` callback.
@@ -479,6 +479,7 @@ The `errorBag` option allows you to specify which error bag to use when handling
import { router } from '@inertiajs/vue3'
router.push({ url: '/users', component: 'Users' })
+
router.replace({
props: (currentProps) => ({ ...currentProps, search: 'John' }),
})
@@ -490,6 +491,7 @@ router.replace({
import { router } from '@inertiajs/react'
router.push({ url: '/users', component: 'Users' })
+
router.replace({
props: (currentProps) => ({ ...currentProps, search: 'John' }),
})
@@ -501,6 +503,7 @@ router.replace({
import { router } from '@inertiajs/svelte'
router.push({ url: '/users', component: 'Users' })
+
router.replace({
props: (currentProps) => ({ ...currentProps, search: 'John' }),
})
@@ -511,6 +514,100 @@ router.replace({
> [!NOTE]
> Make sure that any route you push on the client side is also defined on the server side. If the user refreshes the page, the server will need to know how to render the page.
+### Prop helpers
+
+@available_since core=2.2.0
+
+Inertia provides three helper methods for updating page props without making server requests. These methods are shortcuts to `router.replace()` and automatically set `preserveScroll` and `preserveState` to `true`.
+
+:::tabs key:frameworks
+
+== Vue
+
+```js
+import { router } from '@inertiajs/vue3'
+// Replace a prop value...
+router.replaceProp('user.name', 'Jane Smith')
+// Append to an array prop...
+router.appendToProp('messages', { id: 4, text: 'New message' })
+// Prepend to an array prop...
+router.prependToProp('tags', 'urgent')
+```
+
+== React
+
+```js
+import { router } from '@inertiajs/react'
+// Replace a prop value...
+router.replaceProp('user.name', 'Jane Smith')
+// Append to an array prop...
+router.appendToProp('messages', { id: 4, text: 'New message' })
+// Prepend to an array prop...
+router.prependToProp('tags', 'urgent')
+```
+
+== Svelte 4|Svelte 5
+
+```js
+import { router } from '@inertiajs/svelte'
+// Replace a prop value...
+router.replaceProp('user.name', 'Jane Smith')
+// Append to an array prop...
+router.appendToProp('messages', { id: 4, text: 'New message' })
+// Prepend to an array prop...
+router.prependToProp('tags', 'urgent')
+```
+
+:::
+
+All three methods support dot notation for nested props and can accept a callback function that receives the current value as the first argument and the current page props as the second argument.
+
+:::tabs key:frameworks
+
+== Vue
+
+```js
+import { router } from '@inertiajs/vue3'
+
+router.prependToProp('notifications', (current, props) => {
+ return {
+ id: Date.now(),
+ message: `Hello ${props.user.name}`,
+ timestamp: new Date(),
+ }
+})
+```
+
+== React
+
+```js
+import { router } from '@inertiajs/react'
+
+router.prependToProp('notifications', (current, props) => {
+ return {
+ id: Date.now(),
+ message: `Hello ${props.user.name}`,
+ timestamp: new Date(),
+ }
+})
+```
+
+== Svelte 4|Svelte 5
+
+```js
+import { router } from '@inertiajs/svelte'
+
+router.prependToProp('notifications', (current, props) => {
+ return {
+ id: Date.now(),
+ message: `Hello ${props.user.name}`,
+ timestamp: new Date(),
+ }
+})
+```
+
+:::
+
## State preservation
By default, page visits to the same page create a fresh page component instance. This causes any local state, such as form inputs, scroll positions, and focus states to be lost.
@@ -618,7 +715,7 @@ router.post('/users', data, {
When navigating between pages, Inertia mimics default browser behavior by automatically resetting the scroll position of the document body (as well as any [scroll regions](/guide/scroll-management.md#scroll-regions) you've defined) back to the top of the page.
-You can disable this behaviour by setting the `preserveScroll` option to `false`.
+You can disable this behavior by setting the `preserveScroll` option to `true`.
:::tabs key:frameworks
== Vue
@@ -626,7 +723,7 @@ You can disable this behaviour by setting the `preserveScroll` option to `false`
```js
import { router } from '@inertiajs/vue3'
-router.visit(url, { preserveScroll: false })
+router.visit(url, { preserveScroll: true })
```
== React
@@ -634,7 +731,7 @@ router.visit(url, { preserveScroll: false })
```js
import { router } from '@inertiajs/react'
-router.visit(url, { preserveScroll: false })
+router.visit(url, { preserveScroll: true })
```
== Svelte 4|Svelte 5
@@ -642,7 +739,7 @@ router.visit(url, { preserveScroll: false })
```js
import { router } from '@inertiajs/svelte'
-router.visit(url, { preserveScroll: false })
+router.visit(url, { preserveScroll: true })
```
:::
@@ -711,7 +808,7 @@ router.post('/users', data, {
:::
-For more information regarding this feature, please consult the [scroll management](/guide/scroll-management.md) documentation.
+For more information regarding this feature, check out the [scroll management](/guide/scroll-management.md) documentation.
## Partial reloads
@@ -744,7 +841,7 @@ router.get('/users', { search: 'John' }, { only: ['users'] })
:::
-For more information on this feature, please consult the [partial reloads](/guide/partial-reloads.md) documentation.
+For more information on this feature, check out the [partial reloads](/guide/partial-reloads.md) documentation.
## Visit cancellation
@@ -894,11 +991,10 @@ import { router } from '@inertiajs/vue3'
router.post(url, {
onSuccess: () => {
- return Promise.all([this.doThing(), this.doAnotherThing()])
+ return Promise.all([this.firstTask(), this.secondTask()])
},
onFinish: (visit) => {
- // This won't be called until doThing()
- // and doAnotherThing() have finished.
+ // Not called until firstTask() and secondTask() have finished
},
})
```
@@ -910,11 +1006,10 @@ import { router } from '@inertiajs/react'
router.post(url, {
onSuccess: () => {
- return Promise.all([this.doThing(), this.doAnotherThing()])
+ return Promise.all([this.firstTask(), this.secondTask()])
},
onFinish: (visit) => {
- // This won't be called until doThing()
- // and doAnotherThing() have finished.
+ // Not called until firstTask() and secondTask() have finished
},
})
```
@@ -926,11 +1021,10 @@ import { router } from '@inertiajs/svelte'
router.post(url, {
onSuccess: () => {
- return Promise.all([this.doThing(), this.doAnotherThing()])
+ return Promise.all([this.firstTask(), this.secondTask()])
},
onFinish: (visit) => {
- // This won't be called until doThing()
- // and doAnotherThing() have finished.
+ // Not called until firstTask() and secondTask() have finished
},
})
```
diff --git a/docs/guide/merging-props.md b/docs/guide/merging-props.md
index 35b21d4a..2824c382 100644
--- a/docs/guide/merging-props.md
+++ b/docs/guide/merging-props.md
@@ -1,108 +1,145 @@
# Merging props
-By default, Inertia overwrites props with the same name when reloading a page. However, there are instances, such as pagination or infinite scrolling, where that is not the desired behavior. In these cases, you can merge props instead of overwriting them.
+Inertia overwrites props with the same name when reloading a page. However, you may need to merge new data with existing data instead. For example, when implementing a "load more" button for paginated results. The [Infinite scroll](/guide/infinite-scroll) component uses prop merging under the hood.
-## Server side
+Prop merging only works during [partial reloads](/guide/partial-reloads). Full page visits will always replace props entirely, even if you've marked them for merging.
-### Using `merge`
+## Merge methods
@available_since rails=3.8.0 core=2.0.8
-To specify that a prop should be merged, use the `merge` method on the prop's value. This is ideal for merging simple arrays.
-
-On the client side, Inertia detects that this prop should be merged. If the prop returns an array, it will append the response to the current prop value. If it's an object, it will merge the response with the current prop value.
+To merge a prop instead of overwriting it, you may use the `InertiaRails.merge` method when returning your response.
```ruby
class UsersController < ApplicationController
- include Pagy::Backend
+ include Pagy::Method
def index
- _pagy, records = pagy(User.all)
+ _pagy, records = pagy(:offset, User.all)
render inertia: {
- # simple array:
users: InertiaRails.merge { records.as_json(...) },
- # with match_on parameter for smart merging:
- products: InertiaRails.merge(match_on: 'id') { Product.all.as_json(...) },
}
end
end
```
-### Using `deep_merge`
+The `InertiaRails.merge` method will append new items to existing arrays at the root level.
+
+```ruby
+# Append at root level (default)...
+InertiaRails.merge { items }
+```
+
+@available_since rails=master core=2.2.0
+
+You may change this behavior to prepend items instead.
+
+```ruby
+# Prepend at root level...
+InertiaRails.merge(prepend: true) { items }
+```
+
+For more precise control, you can target specific nested properties for merging while replacing the rest of the object.
+
+```ruby
+# Only append to the 'data' array, replace everything else...
+InertiaRails.merge(append: 'data') { {data: data, meta: meta} }
+
+# Prepend to the 'messages' array...
+InertiaRails.merge(prepend: 'messages') { chat_data }
+```
+
+You can combine multiple operations and target several properties at once.
+
+```ruby
+InertiaRails.merge(
+ append: 'posts',
+ prepend: 'announcements'
+) { forum_data }
+
+# Target multiple properties...
+InertiaRails.merge(
+ append: ['notifications', 'activities']
+) { dashboard_data }
+```
+
+On the client side, Inertia handles all the merging automatically according to your server-side configuration.
+
+## Matching items
@available_since rails=3.8.0 core=2.0.8
-For handling nested objects that include arrays or complex structures, such as pagination objects, use the `deep_merge` method.
+When merging arrays, you may use the `match_on` parameter to match existing items by a specific field and update them instead of appending new ones.
```ruby
-class UsersController < ApplicationController
- include Pagy::Backend
+# Match posts by ID, update existing ones...
+InertiaRails.merge(match_on: 'id') { post_data }
+```
+
+@available_since rails=master core=2.2.0
+
+You may also use append and prepend with a hash to specify the field to match.
+
+```ruby
+# Match posts by ID, update existing ones...
+InertiaRails.merge(append: 'data', match_on: 'data.id') { post_data }
+
+# Same as above, but using a hash shortcut...
+InertiaRails.merge(append: { data: 'id' }) { post_data }
+
+# Multiple properties with different match fields...
+InertiaRails.merge(append: {
+ 'users.data' => 'id',
+ 'messages' => 'uuid',
+}) { complex_data }
+```
+
+In the first two examples, Inertia will iterate over the data array and attempt to match each item by its id field. If a match is found, the existing item will be replaced. If no match is found, the new item will be appended.
+
+## Deep merge
+
+@available_since rails=3.8.0 core=2.0.8
+Instead of specifying which nested paths should be merged, you may use `InertiaRails.deep_merge` to ensure a deep merge of the entire structure.
+
+```ruby
+class ChatController < ApplicationController
def index
- pagy, records = pagy(User.all)
+ chat_data = [
+ messages: [
+ [id: 4, text: 'Hello there!', user: 'Alice'],
+ [id: 5, text: 'How are you?', user: 'Bob'],
+ ],
+ online: 12,
+ ]
render inertia: {
- # pagination object:
- data: InertiaRails.deep_merge {
- {
- records: records.as_json(...),
- pagy: pagy_metadata(pagy)
- }
- },
- # nested objects with match_on:
- categories: InertiaRails.deep_merge(match_on: %w[items.id tags.id]) {
- {
- items: Category.all.as_json(...),
- tags: Tag.all.as_json(...)
- }
- }
+ chat: InertiaRails.deep_merge(chat_data, match_on: 'messages.id')
}
end
end
```
-If you have opted to use `deep_merge`, Inertia ensures a deep merge of the entire structure, including nested objects and arrays.
-
-### Smart merging with `match_on`
-
-@available_since rails=3.11.0 core=2.0.13
+> [!NOTE] > `InertiaRails.deep_merge` was introduced before `InertiaRails.merge` had support for prepending and targeting nested paths. In most cases, `InertiaRails.merge` with its append and prepend parameters should be sufficient.
-By default, arrays are simply appended during merging. If you need to update specific items in an array or replace them based on a unique identifier, you can use the `match_on` parameter.
+## Client side visits
-The `match_on` parameter enables smart merging by specifying a field to match on when merging arrays of objects:
+You can also merge props directly on the client side without making a server request using [client side visits](/guide/manual-visits#client-side-visits). Inertia provides [prop helper methods](/guide/manual-visits#prop-helpers) that allow you to append, prepend, or replace prop values.
-- For `merge` with simple arrays, specify the object key to match on (e.g., `'id'`)
-- For `deep_merge` with nested structures, use dot notation to specify the path (e.g., `'items.id'`)
+## Combining with deferred props
You can also combine [deferred props](/guide/deferred-props) with mergeable props to defer the loading of the prop and ultimately mark it as mergeable once it's loaded.
```ruby
class UsersController < ApplicationController
- include Pagy::Backend
+ include Pagy::Method
def index
- pagy, records = pagy(User.all)
+ pagy, records = pagy(:offset, User.all)
render inertia: {
- # simple array:
- users: InertiaRails.defer(merge: true) { records.as_json(...) },
- # pagination object:
- data: InertiaRails.defer(deep_merge: true) {
- {
- records: records.as_json(...),
- pagy: pagy_metadata(pagy)
- }
- },
- # with match_on parameter:
- products: InertiaRails.defer(merge: true, match_on: 'id') { products.as_json(...) },
- # nested objects with match_on:
- categories: InertiaRails.defer(deep_merge: true, match_on: %w[items.id tags.id]) {
- {
- items: Category.all.as_json(...),
- tags: Tag.all.as_json(...)
- }
- }
+ results: InertiaRails.defer(deep_merge: true) { records.as_json(...) },
}
end
end
diff --git a/docs/guide/pages.md b/docs/guide/pages.md
index d380bcb3..adbec25d 100644
--- a/docs/guide/pages.md
+++ b/docs/guide/pages.md
@@ -101,7 +101,7 @@ See [the responses documentation](/guide/responses) for more information on how
## Creating layouts
-While not required, for most projects it makes sense to create a site layout that all of your pages can extend. You may have noticed in our page example above that we're wrapping the page content within a `` component. Here's an example of such a component:
+While not required, for most projects it makes sense to create a layout component that all of your pages can use. You may have noticed in our page example above that we're wrapping the page content within a `` component. Here's an example of such a component:
:::tabs key:frameworks
== Vue
diff --git a/docs/guide/prefetching.md b/docs/guide/prefetching.md
index 307db902..d4e6b379 100644
--- a/docs/guide/prefetching.md
+++ b/docs/guide/prefetching.md
@@ -4,7 +4,7 @@ Inertia supports prefetching data for pages that are likely to be visited next.
## Link prefetching
-To prefetch data for a page, you can use the `prefetch` method on the Inertia link component. By default, Inertia will prefetch the data for the page when the user hovers over the link after more than 75ms.
+To prefetch data for a page, you can add the `prefetch` prop to the Inertia link component. By default, Inertia will prefetch the data for the page when the user hovers over the link for more than 75ms.
:::tabs key:frameworks
== Vue
@@ -94,7 +94,7 @@ export default () => (
:::
-You can also start prefetching on `mousedown` by passing the `click` value to the `prefetch` prop.
+Instead of prefetching on hover, you can also start prefetching on `mousedown` by passing the `click` value to the `prefetch` prop.
:::tabs key:frameworks
== Vue
@@ -172,7 +172,7 @@ export default () => (
:::
-You can also combine strategies by passing an array of values to the `prefetch` prop.
+You can also combine prefetch strategies by passing an array of values to the `prefetch` prop.
:::tabs key:frameworks
== Vue
@@ -213,7 +213,7 @@ export default () => (
## Programmatic prefetching
-You can also prefetch data programmatically using `router.prefetch`. The signature is identical to `router.visit` with the exception of a third argument that allows you to specify prefetch options.
+You can prefetch data programmatically using `router.prefetch`. This method's signature is identical to `router.visit` with the exception of a third argument that allows you to specify prefetch options.
When the `cacheFor` option is not specified, it defaults to 30 seconds.
@@ -296,7 +296,7 @@ const { lastUpdatedAt, isPrefetching, isPrefetched, flush } = usePrefetch({
@available_since core=2.1.2
-Cache tags allow you to group related prefetched data and invalidate it all at once when specific events occur.
+Cache tags allow you to group related prefetched data and invalidate all cached data with that tag when specific events occur.
To tag cached data, pass a `cacheTags` prop to your `Link` component.
@@ -326,7 +326,7 @@ import { Link } from '@inertiajs/react'
Dashboard
```
-== Svelte 4| Svelte 5
+== Svelte 4|Svelte 5
```svelte
import {inertia} from '@inertiajs/svelte'
@@ -362,7 +362,8 @@ router.flush('/users', { method: 'get', data: { page: 2 } })
// Using the usePrefetch hook
const { flush } = usePrefetch()
-flush() // Flush cache for the current page
+// Flush cache for the current page
+flush()
```
For more granular control, you can flush cached data by their tags using `router.flushByCacheTags`. This removes any cached response that contains _any_ of the specified tags.
@@ -375,6 +376,38 @@ router.flushByCacheTags('users')
router.flushByCacheTags(['dashboard', 'stats'])
```
+### Automatic cache flushing
+
+By default, Inertia does not automatically flush the prefetch cache when you navigate to new pages. Cached data is only evicted when it expires based on the cache duration. If you want to flush all cached data on every navigation, you can set up an event listener.
+
+:::tabs key:frameworks
+
+== Vue
+
+```js
+import { router } from '@inertiajs/vue3'
+
+router.on('navigate', () => router.flushAll())
+```
+
+== React
+
+```js
+import { router } from '@inertiajs/react'
+
+router.on('navigate', () => router.flushAll())
+```
+
+== Svelte 4|Svelte 5
+
+```js
+import { router } from '@inertiajs/svelte'
+
+router.on('navigate', () => router.flushAll())
+```
+
+:::
+
### Invalidate on requests
@available_since core=2.1.2
@@ -421,7 +454,7 @@ export default () => (
)
```
-== Svelte 4| Svelte 5
+== Svelte 4|Svelte 5
```svelte