From 9611802e909a295f0e396f8b27c0c5d3d4b3ba79 Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov Date: Thu, 16 Oct 2025 23:07:33 +0300 Subject: [PATCH 1/6] Fix flaky test --- spec/inertia/action_filter_spec.rb | 4 ---- spec/inertia/middleware_spec.rb | 16 ++++++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/spec/inertia/action_filter_spec.rb b/spec/inertia/action_filter_spec.rb index 47eeca4b..eebcd08f 100644 --- a/spec/inertia/action_filter_spec.rb +++ b/spec/inertia/action_filter_spec.rb @@ -1,9 +1,5 @@ # frozen_string_literal: true -# spec/lib/inertia_rails/action_filter_spec.rb - -require 'rails_helper' - RSpec.describe InertiaRails::ActionFilter do let(:controller) do instance_double( diff --git a/spec/inertia/middleware_spec.rb b/spec/inertia/middleware_spec.rb index ef62e643..ff9e5ed3 100644 --- a/spec/inertia/middleware_spec.rb +++ b/spec/inertia/middleware_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe InertiaRails::Middleware, type: :request do +RSpec.describe 'InertiaRails::Middleware', type: :request do context 'the version is set' do with_inertia_config version: '1.0' @@ -52,21 +52,25 @@ end it 'is thread safe' do - delete_request_proc = -> { delete redirect_test_path, headers: { 'X-Inertia' => true } } - get_request_proc = -> { get empty_test_path } + # Capture route paths to fix flakiness + redirect_path = redirect_test_path + empty_path = empty_test_path - statusses = [] + delete_request_proc = -> { delete redirect_path, headers: { 'X-Inertia' => true } } + get_request_proc = -> { get empty_path } + + statuses = Concurrent::Array.new threads = [] 100.times do - threads << Thread.new { statusses << delete_request_proc.call } + threads << Thread.new { statuses << delete_request_proc.call } threads << Thread.new { get_request_proc.call } end threads.each(&:join) - expect(statusses.uniq).to eq([303]) + expect(statuses.uniq).to eq([303]) end end end From 46436f6b109450979504eb2316e53cb260b9349c Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov Date: Fri, 17 Oct 2025 15:15:09 +0300 Subject: [PATCH 2/6] docs: add Opt/React/Vue/Svelte components --- docs/.vitepress/theme/components/Opt.vue | 34 ++++++++ docs/.vitepress/theme/components/React.vue | 9 +++ docs/.vitepress/theme/components/Svelte.vue | 9 +++ docs/.vitepress/theme/components/Svelte4.vue | 9 +++ docs/.vitepress/theme/components/Svelte5.vue | 9 +++ docs/.vitepress/theme/components/Vue.vue | 9 +++ docs/.vitepress/theme/components/index.ts | 8 +- .../theme/composables/useTabsSelectedState.ts | 78 +++++++++++++++++++ docs/.vitepress/theme/index.ts | 16 +++- 9 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 docs/.vitepress/theme/components/Opt.vue create mode 100644 docs/.vitepress/theme/components/React.vue create mode 100644 docs/.vitepress/theme/components/Svelte.vue create mode 100644 docs/.vitepress/theme/components/Svelte4.vue create mode 100644 docs/.vitepress/theme/components/Svelte5.vue create mode 100644 docs/.vitepress/theme/components/Vue.vue create mode 100644 docs/.vitepress/theme/composables/useTabsSelectedState.ts 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() From 37e9f092ac60c6c97f91ce0d231d2144c7391540 Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov Date: Fri, 17 Oct 2025 15:19:56 +0300 Subject: [PATCH 3/6] docs: update docs --- docs/guide/code-splitting.md | 2 +- docs/guide/error-handling.md | 6 +- docs/guide/forms.md | 151 +++++++++++++++++++++++----- docs/guide/history-encryption.md | 12 +-- docs/guide/links.md | 6 +- docs/guide/manual-visits.md | 128 +++++++++++++++++++---- docs/guide/pages.md | 2 +- docs/guide/prefetching.md | 57 ++++++++--- docs/guide/progress-indicators.md | 81 ++++++++++++++- docs/guide/remembering-state.md | 16 +-- docs/guide/scroll-management.md | 14 ++- docs/guide/server-side-rendering.md | 6 +- docs/guide/the-protocol.md | 147 ++++++++++++++++++++++++++- 13 files changed, 538 insertions(+), 90 deletions(-) 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 + + + +``` + +== React + +```jsx +import { InfiniteScroll, router } from '@inertiajs/react' + +export default function Users({ users }) { + const show = (role) => { + router.visit(route('users'), { + data: { filter: { role } }, + only: ['users'], + reset: ['users'], + }) + } + + return ( + <> + + + + + {users.map((user) => ( +
{user.name}
+ ))} +
+ + ) +} +``` + +== Svelte 4|Svelte 5 + +```svelte + + + + + + + {#each users as user (user.id)} +
{user.name}
+ {/each} +
+``` + +:::: + +For more information about the reset option, see the [Resetting props](/guide/merging-props#resetting-props) documentation. + +## Loading direction + +The infinite scroll component loads content in both directions when you scroll near the start or end. You can control this behavior using the `only-next` and `only-previous` props. + +:::tabs key:frameworks +== Vue + +```vue + +``` + +== React + +```jsx +/* Only load the next page */ +export default () => ( + + {/* ... */} + +) + +/* Only load the previous page */ +export default () => ( + + {/* ... */} + +) + +/* Load in both directions (default) */ +export default () => ( +{/* ... */} +) +``` + +== Svelte 4|Svelte 5 + +```svelte + + + + + + + + + + + + + + +``` + +:::: + +The default option is particularly useful when users start on a middle page and need to scroll in both directions to access all content. + +## Reverse mode + +For chat applications, timelines, or interfaces where content is sorted descendingly (newest items at the bottom), you can enable reverse mode. This configures the component to load older content when scrolling upward. + +:::tabs key:frameworks +== Vue + +```vue + +``` + +== React + +```jsx + + {/* ... */} + +``` + +== Svelte 4|Svelte 5 + +```svelte + + + +``` + +:::: + +In reverse mode, the component flips the loading directions so that scrolling up loads the next page (older content) and scrolling down loads the previous page (newer content). The component handles the loading positioning, but you are responsible for reversing your content to display in the correct order. + +Reverse mode also enables automatic scrolling to the bottom on initial load, which you can disable with `:auto-scroll="false"``autoScroll={false}``auto-scroll={false}`. + +:::tabs key:frameworks +== Vue + +```vue + +``` + +== React + +```jsx + + {/* ... */} + +``` + +== Svelte 4|Svelte 5 + +```svelte + + + +``` + +:::: + +## Manual mode + +Manual mode disables automatic loading when scrolling and allows you to control when content loads through the `next` and `previous` slots. For more details about available slot properties and customization options, see the [Slots](#slots) documentation. + +:::tabs key:frameworks +== Vue + +```vue + +``` + +== React + +```jsx +import { InfiniteScroll } from '@inertiajs/react' + +export default ({ users }) => ( + + hasMore && ( + + ) + } + next={({ loading, fetch, hasMore }) => + hasMore && ( + + ) + } + > + {users.map((user) => ( +
{user.name}
+ ))} +
+) +``` + +== Svelte 4|Svelte 5 + +```svelte + + + +
+ {#if hasMore} + + {/if} +
+ + {#each users as user (user.id)} +
{user.name}
+ {/each} + +
+ {#if hasMore} + + {/if} +
+
+``` + +:::: + +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 + +``` + +== 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) => ( +
{user.name}
+ ))} +
+) +``` + +== Svelte 4|Svelte 5 + +```svelte + + + +
+ {#if manualMode && hasMore} + + {/if} +
+ {#each users as user (user.id)} +
{user.name}
+ {/each} +
+ {#if manualMode && hasMore} + + {/if} +
+
+``` + +::: + +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 + +``` + +== React + +```jsx + + {products.map((product) => ( +
  • {product.name}
  • + ))} +
    +``` + +== Svelte 4|Svelte 5 + +```svelte + + {#each products as product (product.id)} +
  • {product.name}
  • + {/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 + +``` + +== React + +```jsx + + + + + + + + + {users.map((user) => ( + + + + ))} + +
    Name
    {user.name}
    +
    +``` + +== Svelte 4|Svelte 5 + +```svelte + + + + + + + {#each users as user (user.id)} + + + + {/each} + +
    Name
    {user.name}
    +
    +``` + +:::: + +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 + +``` + +== React + +```jsx + + + + + + + + + {users.data.map((user) => ( + + + + ))} + + + + + + +
    Name
    {user.name}
    Footer
    +
    +``` + +== Svelte 4|Svelte 5 + +```svelte + + + + + + + {#each users.data as user (user.id)} + + + + {/each} + + + + +
    Name
    {user.name}
    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 + + + +``` + +== React + +```jsx +import { useRef } from 'react' + +export default ({ users }) => { + const tableHeader = useRef() + const tableFooter = useRef() + const tableBody = useRef() + + return ( + tableBody.current} + startElement={() => tableHeader.current} + endElement={() => tableFooter.current} + > + + + + + + + + {users.data.map((user) => ( + + + + ))} + + + + + + +
    Name
    {user.name}
    Footer
    +
    + ) +} +``` + +== Svelte 4|Svelte 5 + +```svelte + + + tableBody} + start-element={() => tableHeader} + end-element={() => tableFooter} +> + + + + + + {#each users.data as user (user.id)} + + + + {/each} + + + + +
    Name
    {user.name}
    Footer
    +
    +``` + +:::: + +## 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 + +``` + +== 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::Backend + + def index + pagy_users, users = pagy(User.all, page_param: :users) + pagy_orders, orders = pagy(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) => ( +
    {user.name}
    + ))} +
    + + ) +} +``` + +== Svelte 4|Svelte 5 + +```svelte + + + + + + {#each users.data as user (user.id)} +
    {user.name}
    + {/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/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/merging-props.md b/docs/guide/merging-props.md index 35b21d4a..283749b3 100644 --- a/docs/guide/merging-props.md +++ b/docs/guide/merging-props.md @@ -1,16 +1,14 @@ # 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 @@ -20,60 +18,116 @@ class UsersController < ApplicationController _pagy, records = pagy(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. @@ -85,24 +139,7 @@ class UsersController < ApplicationController pagy, records = pagy(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/title-and-meta.md b/docs/guide/title-and-meta.md index 8cecdeef..28d73be9 100644 --- a/docs/guide/title-and-meta.md +++ b/docs/guide/title-and-meta.md @@ -317,7 +317,7 @@ export default ({ title, children }) => { ::: -Once you have created the custom component, you may simply start using the custom component in your pages. +Once you have created the custom component, you can just start using it in your pages. :::tabs key:frameworks == Vue From c7cc2e0bea35eaffcfd3b319d33fdce6f4bfd642 Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov Date: Fri, 17 Oct 2025 17:35:10 +0300 Subject: [PATCH 5/6] Introduce InertiaRails.scroll --- Gemfile | 3 + lib/inertia_rails/defer_prop.rb | 21 +- lib/inertia_rails/inertia_rails.rb | 10 +- lib/inertia_rails/merge_prop.rb | 15 +- lib/inertia_rails/prop_mergeable.rb | 83 ++++ lib/inertia_rails/renderer.rb | 101 ++++- lib/inertia_rails/scroll_metadata.rb | 96 +++++ lib/inertia_rails/scroll_prop.rb | 35 ++ .../inertia_render_test_controller.rb | 37 ++ spec/dummy/config/routes.rb | 4 + spec/inertia/merge_prop_spec.rb | 104 +++++ spec/inertia/prop_mergeable_spec.rb | 388 ++++++++++++++++++ spec/inertia/rendering_spec.rb | 90 ++++ spec/inertia/scroll_metadata_spec.rb | 372 +++++++++++++++++ spec/inertia/scroll_prop_spec.rb | 343 ++++++++++++++++ spec/support/shared_examples.rb | 10 +- 16 files changed, 1659 insertions(+), 53 deletions(-) create mode 100644 lib/inertia_rails/prop_mergeable.rb create mode 100644 lib/inertia_rails/scroll_metadata.rb create mode 100644 lib/inertia_rails/scroll_prop.rb create mode 100644 spec/inertia/prop_mergeable_spec.rb create mode 100644 spec/inertia/scroll_metadata_spec.rb create mode 100644 spec/inertia/scroll_prop_spec.rb diff --git a/Gemfile b/Gemfile index 191756a6..f102bf2e 100644 --- a/Gemfile +++ b/Gemfile @@ -17,3 +17,6 @@ gem 'responders' gem 'rspec-rails', '~> 6.0' gem 'rubocop', '~> 1.21' gem 'sqlite3' + +gem 'kaminari' +gem 'pagy' diff --git a/lib/inertia_rails/defer_prop.rb b/lib/inertia_rails/defer_prop.rb index ec85ac04..eb235536 100644 --- a/lib/inertia_rails/defer_prop.rb +++ b/lib/inertia_rails/defer_prop.rb @@ -2,27 +2,16 @@ module InertiaRails class DeferProp < IgnoreOnFirstLoadProp - DEFAULT_GROUP = 'default' + prepend PropMergeable - attr_reader :group, :match_on + DEFAULT_GROUP = 'default' - def initialize(group: nil, merge: nil, deep_merge: nil, match_on: nil, &block) - raise ArgumentError, 'Cannot set both `deep_merge` and `merge` to true' if deep_merge && merge + attr_reader :group + def initialize(**props, &block) super(&block) - @group = group || DEFAULT_GROUP - @merge = merge || deep_merge - @deep_merge = deep_merge - @match_on = match_on.nil? ? nil : Array(match_on) - end - - def merge? - @merge - end - - def deep_merge? - @deep_merge + @group = props[:group] || DEFAULT_GROUP end end end diff --git a/lib/inertia_rails/inertia_rails.rb b/lib/inertia_rails/inertia_rails.rb index 47d5837a..85c19dd2 100644 --- a/lib/inertia_rails/inertia_rails.rb +++ b/lib/inertia_rails/inertia_rails.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'inertia_rails/prop_mergeable' require 'inertia_rails/base_prop' require 'inertia_rails/ignore_on_first_load_prop' require 'inertia_rails/always_prop' @@ -7,6 +8,7 @@ require 'inertia_rails/optional_prop' require 'inertia_rails/defer_prop' require 'inertia_rails/merge_prop' +require 'inertia_rails/scroll_prop' require 'inertia_rails/configuration' require 'inertia_rails/meta_tag' @@ -34,8 +36,8 @@ def always(&block) AlwaysProp.new(&block) end - def merge(match_on: nil, &block) - MergeProp.new(match_on: match_on, &block) + def merge(...) + MergeProp.new(...) end def deep_merge(match_on: nil, &block) @@ -45,5 +47,9 @@ def deep_merge(match_on: nil, &block) def defer(group: nil, merge: nil, deep_merge: nil, match_on: nil, &block) DeferProp.new(group: group, merge: merge, deep_merge: deep_merge, match_on: match_on, &block) end + + def scroll(metadata = nil, **options, &block) + ScrollProp.new(metadata: metadata, **options, &block) + end end end diff --git a/lib/inertia_rails/merge_prop.rb b/lib/inertia_rails/merge_prop.rb index 639a1e95..e3172af4 100644 --- a/lib/inertia_rails/merge_prop.rb +++ b/lib/inertia_rails/merge_prop.rb @@ -2,20 +2,11 @@ module InertiaRails class MergeProp < BaseProp - attr_reader :match_on + prepend PropMergeable - def initialize(deep_merge: false, match_on: nil, &block) + def initialize(**_props, &block) super(&block) - @deep_merge = deep_merge - @match_on = match_on.nil? ? nil : Array(match_on) - end - - def merge? - true - end - - def deep_merge? - @deep_merge + @merge = true end end end diff --git a/lib/inertia_rails/prop_mergeable.rb b/lib/inertia_rails/prop_mergeable.rb new file mode 100644 index 00000000..50d6cbae --- /dev/null +++ b/lib/inertia_rails/prop_mergeable.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module InertiaRails + module PropMergeable + attr_reader :match_on, :appends_at_paths, :prepends_at_paths + + def initialize(**props, &block) + raise ArgumentError, 'Cannot set both `deep_merge` and `merge` to true' if props[:deep_merge] && props[:merge] + + @deep_merge = props.fetch(:deep_merge, false) + @merge = props[:merge] || @deep_merge + @match_on = props[:match_on].nil? ? nil : Array(props[:match_on]) + @appends_at_paths = [] + @prepends_at_paths = [] + @append = true + + append(props[:append]) if props.key?(:append) + prepend(props[:prepend]) if props.key?(:prepend) + + super + end + + def appends_at_root? + @append && merges_at_root? + end + + def prepends_at_root? + !@append && merges_at_root? + end + + def merges_at_root? + merge? && appends_at_paths.none? && prepends_at_paths.none? + end + + def merge? + @merge + end + + def deep_merge? + @deep_merge + end + + private + + def append(path, match_on: nil) + case path + when TrueClass, FalseClass + @append = path + when String + @appends_at_paths << path + when Array + @appends_at_paths += path + when Hash + @match_on ||= [] + path.each do |key, value| + @appends_at_paths << key.to_s + @match_on << "#{key}.#{value}" if value + end + end + + (@match_on ||= []) << "#{path}.#{match_on}" if match_on && path.is_a?(String) + end + + def prepend(path, match_on: nil) + case path + when TrueClass, FalseClass + @append = !path + when String + @prepends_at_paths << path + when Array + @prepends_at_paths += path + when Hash + @match_on ||= [] + path.each do |key, value| + @prepends_at_paths << key.to_s + @match_on << "#{key}.#{value}" if value + end + end + + (@match_on ||= []) << "#{path}.#{match_on}" if match_on && path.is_a?(String) + end + end +end diff --git a/lib/inertia_rails/renderer.rb b/lib/inertia_rails/renderer.rb index 3d2359bf..ef2da227 100644 --- a/lib/inertia_rails/renderer.rb +++ b/lib/inertia_rails/renderer.rb @@ -104,12 +104,13 @@ def computed_props .tap do |props| # Add meta tags last (never transformed) props[:_inertia_meta] = meta_tags if meta_tags.present? end - # rubocop:enable Style/MultilineBlockChain end def page - default_page = { + return @page if defined?(@page) + + @page = { component: component, props: computed_props, url: @request.original_fullpath, @@ -119,21 +120,11 @@ def page } deferred_props = deferred_props_keys - default_page[:deferredProps] = deferred_props if deferred_props.present? - - deep_merge_props, merge_props = all_merge_props.partition do |_key, prop| - prop.deep_merge? - end + @page[:deferredProps] = deferred_props if deferred_props.present? + @page[:scrollProps] = scroll_props if scroll_props.present? + @page.merge!(resolve_merge_props) - match_props_on = all_merge_props.filter_map do |key, prop| - prop.match_on.map { |ms| "#{key}.#{ms}" } if prop.match_on.present? - end.flatten - - default_page[:mergeProps] = merge_props.map(&:first) if merge_props.present? - default_page[:deepMergeProps] = deep_merge_props.map(&:first) if deep_merge_props.present? - default_page[:matchPropsOn] = match_props_on if match_props_on.present? - - default_page + @page end def deep_transform_props(props, parent_path = []) @@ -165,10 +156,28 @@ def deferred_props_keys end end - def all_merge_props - @all_merge_props ||= @props.select do |key, prop| + def resolve_merge_props + deep_merge_props, merge_props = all_merge_props.partition do |_key, prop| + prop.deep_merge? + end + + { + mergeProps: append_merge_props(merge_props), + prependProps: prepend_merge_props(merge_props), + deepMergeProps: deep_merge_props.map!(&:first), + matchPropsOn: resolve_match_on_props, + }.delete_if { |_, v| v.blank? } + end + + def resolve_match_on_props + all_merge_props.filter_map do |key, prop| + prop.match_on.map! { |ms| "#{key}.#{ms}" } if prop.match_on.present? + end.flatten + end + + def requested_merge_props + @requested_merge_props ||= @props.select do |key, prop| next unless prop.try(:merge?) - next if reset_keys.include?(key) next if rendering_partial_component? && ( (partial_keys.present? && partial_keys.exclude?(key.name)) || (partial_except_keys.present? && partial_except_keys.include?(key.name)) @@ -178,16 +187,64 @@ def all_merge_props end end + def append_merge_props(props) + return props if props.empty? + + root_append_props, nested_append_props = props.partition { |_key, prop| prop.appends_at_root? } + + result = Set.new(root_append_props.map!(&:first)) + + nested_append_props.each do |key, prop| + prop.appends_at_paths.each do |path| + result.add("#{key}.#{path}") + end + end + + result.to_a + end + + def prepend_merge_props(props) + return props if props.empty? + + root_prepend_props, nested_prepend_props = props.partition { |_key, prop| prop.prepends_at_root? } + + result = Set.new(root_prepend_props.map!(&:first)) + + nested_prepend_props.each do |key, prop| + prop.prepends_at_paths.each do |path| + result.add("#{key}.#{path}") + end + end + + result.to_a + end + + def scroll_props + return @scroll_props if defined?(@scroll_props) + + @scroll_props = {} + requested_merge_props.each do |key, prop| + next unless prop.is_a?(ScrollProp) + + @scroll_props[key] = prop.metadata.merge!(reset: reset_keys.include?(key)) + end + @scroll_props + end + + def all_merge_props + @all_merge_props ||= requested_merge_props.reject { |key,| reset_keys.include?(key) } + end + def partial_keys - @partial_keys ||= (@request.headers['X-Inertia-Partial-Data'] || '').split(',').compact + @partial_keys ||= (@request.headers['X-Inertia-Partial-Data'] || '').split(',').compact_blank! end def reset_keys - (@request.headers['X-Inertia-Reset'] || '').split(',').compact.map(&:to_sym) + @reset_keys ||= (@request.headers['X-Inertia-Reset'] || '').split(',').compact_blank!.map!(&:to_sym) end def partial_except_keys - (@request.headers['X-Inertia-Partial-Except'] || '').split(',').compact + @partial_except_keys ||= (@request.headers['X-Inertia-Partial-Except'] || '').split(',').compact_blank! end def rendering_partial_component? diff --git a/lib/inertia_rails/scroll_metadata.rb b/lib/inertia_rails/scroll_metadata.rb new file mode 100644 index 00000000..86091442 --- /dev/null +++ b/lib/inertia_rails/scroll_metadata.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module InertiaRails + module ScrollMetadata + class MissingMetadataAdapterError < StandardError; end + + class Props + def initialize(page_name:, previous_page:, next_page:, current_page:) + @page_name = page_name + @previous_page = previous_page + @next_page = next_page + @current_page = current_page + end + + def as_json(_options = nil) + { + pageName: @page_name, + previousPage: @previous_page, + nextPage: @next_page, + currentPage: @current_page, + } + end + end + + class KaminariAdapter + def match?(metadata) + defined?(Kaminari) && metadata.is_a?(Kaminari::PageScopeMethods) + end + + def call(metadata, **_options) + { + page_name: (Kaminari.config.param_name || 'page').to_s, + previous_page: metadata.prev_page, + next_page: metadata.next_page, + current_page: metadata.current_page, + } + end + end + + class PagyAdapter + def match?(metadata) + defined?(Pagy) && metadata.is_a?(Pagy) + end + + def call(metadata, **_options) + { + page_name: metadata.vars.fetch(:page_param).to_s, + previous_page: metadata.prev, + next_page: metadata.next, + current_page: metadata.page, + } + end + end + + class HashAdapter + def match?(metadata) + metadata.is_a?(Hash) + end + + def call(metadata, **_options) + { + page_name: metadata.fetch(:page_name), + previous_page: metadata.fetch(:previous_page), + next_page: metadata.fetch(:next_page), + current_page: metadata.fetch(:current_page), + } + end + end + + class << self + attr_accessor :adapters + + def extract(metadata, **options) + overrides = options.slice(:page_name, :previous_page, :next_page, :current_page) + + adapters.each do |adapter| + next unless adapter.match?(metadata) + + return Props.new(**adapter.call(metadata, **options).merge!(overrides)).as_json + end + + begin + Props.new(**overrides).as_json + rescue ArgumentError + raise MissingMetadataAdapterError, "No ScrollMetadata adapter found for #{metadata}" + end + end + + def register_adapter(adapter) + adapters.unshift(adapter.new) + end + end + + self.adapters = [KaminariAdapter, PagyAdapter, HashAdapter].map(&:new) + end +end diff --git a/lib/inertia_rails/scroll_prop.rb b/lib/inertia_rails/scroll_prop.rb new file mode 100644 index 00000000..2d7705fa --- /dev/null +++ b/lib/inertia_rails/scroll_prop.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative 'scroll_metadata' + +module InertiaRails + class ScrollProp < BaseProp + prepend PropMergeable + + def initialize(**options, &block) + super(&block) + + @merge = true + @metadata = options.delete(:metadata) + @wrapper = options.delete(:wrapper) + + @options = options + end + + def call(controller) + @value = super + configure_merge_intent(controller.request.headers['X-Inertia-Infinite-Scroll-Merge-Intent']) + @value + end + + def metadata + ScrollMetadata.extract(@metadata, **@options) + end + + private + + def configure_merge_intent(scroll_intent) + scroll_intent == 'prepend' ? prepend(@wrapper || true) : append(@wrapper || true) + end + end +end diff --git a/spec/dummy/app/controllers/inertia_render_test_controller.rb b/spec/dummy/app/controllers/inertia_render_test_controller.rb index 85928972..0c256b0c 100644 --- a/spec/dummy/app/controllers/inertia_render_test_controller.rb +++ b/spec/dummy/app/controllers/inertia_render_test_controller.rb @@ -128,4 +128,41 @@ def deferred_props grit: InertiaRails.defer { 'intense' }, } end + + def scroll_test + pagy = Pagy.new( + vars: { page_param: 'page' }, + prev: nil, + next: 2, + page: 1, + count: 100 + ) + + render inertia: 'TestComponent', props: { + users: InertiaRails.scroll(pagy) { [{ id: 1, name: 'User 1' }, { id: 2, name: 'User 2' }] }, + } + end + + def prepend_merge_test + render inertia: 'TestComponent', props: { + prepend_prop: InertiaRails.merge(prepend: true) { %w[item1 item2] }, + append_prop: InertiaRails.merge { %w[item3 item4] }, + } + end + + def nested_paths_test + render inertia: 'TestComponent', props: { + foo: InertiaRails.merge(append: { data: :id }) { { data: [{ id: 1 }, { id: 2 }] } }, + bar: InertiaRails.merge(prepend: { 'data.items' => 'uuid' }) do + { data: { items: [{ uuid: 1 }, { uuid: 2 }] } } + end, + } + end + + def reset_test + render inertia: 'TestComponent', props: { + merge_prop: InertiaRails.merge { 'merge value' }, + regular_prop: 'regular value', + } + end end diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index 131a1de3..91515bc2 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -38,6 +38,10 @@ get 'except_props' => 'inertia_render_test#except_props' get 'merge_props' => 'inertia_render_test#merge_props' get 'deferred_props' => 'inertia_render_test#deferred_props' + get 'scroll_test' => 'inertia_render_test#scroll_test' + get 'prepend_merge_test' => 'inertia_render_test#prepend_merge_test' + get 'nested_paths_test' => 'inertia_render_test#nested_paths_test' + get 'reset_test' => 'inertia_render_test#reset_test' get 'non_inertiafied' => 'inertia_test#non_inertiafied' get 'deeply_nested_props' => 'inertia_render_test#deeply_nested_props' diff --git a/spec/inertia/merge_prop_spec.rb b/spec/inertia/merge_prop_spec.rb index 2ef33fcc..9828202e 100644 --- a/spec/inertia/merge_prop_spec.rb +++ b/spec/inertia/merge_prop_spec.rb @@ -24,4 +24,108 @@ it { is_expected.to be true } end end + + describe 'append/prepend behavior' do + it 'appends by default' do + prop = described_class.new { [] } + + expect(prop.appends_at_root?).to be true + expect(prop.prepends_at_root?).to be false + expect(prop.appends_at_paths).to eq([]) + expect(prop.prepends_at_paths).to eq([]) + expect(prop.match_on).to be_nil + end + + it 'can be configured to prepend' do + prop = described_class.new(prepend: true) { [] } + + expect(prop.appends_at_root?).to be false + expect(prop.prepends_at_root?).to be true + expect(prop.appends_at_paths).to eq([]) + expect(prop.prepends_at_paths).to eq([]) + expect(prop.match_on).to be_nil + end + + it 'supports appending with nested merge paths' do + prop = described_class.new(append: 'data') { [] } + + expect(prop.appends_at_root?).to be false + expect(prop.prepends_at_root?).to be false + expect(prop.appends_at_paths).to eq(['data']) + expect(prop.prepends_at_paths).to eq([]) + expect(prop.match_on).to be_nil + end + + it 'supports appending with nested merge paths and match_on' do + prop = described_class.new(append: { data: 'id' }) { [] } + + expect(prop.appends_at_root?).to be false + expect(prop.prepends_at_root?).to be false + expect(prop.appends_at_paths).to eq(['data']) + expect(prop.prepends_at_paths).to eq([]) + expect(prop.match_on).to eq(['data.id']) + end + + it 'supports prepending with nested merge paths' do + prop = described_class.new(prepend: 'data') { [] } + + expect(prop.appends_at_root?).to be false + expect(prop.prepends_at_root?).to be false + expect(prop.appends_at_paths).to eq([]) + expect(prop.prepends_at_paths).to eq(['data']) + expect(prop.match_on).to be_nil + end + + it 'supports prepending with nested merge paths and match_on' do + prop = described_class.new(prepend: { data: 'id' }) { [] } + + expect(prop.appends_at_root?).to be false + expect(prop.prepends_at_root?).to be false + expect(prop.appends_at_paths).to eq([]) + expect(prop.prepends_at_paths).to eq(['data']) + expect(prop.match_on).to eq(['data.id']) + end + + it 'supports append with nested merge paths as array' do + prop = described_class.new(append: %w[data items]) { [] } + + expect(prop.appends_at_root?).to be false + expect(prop.prepends_at_root?).to be false + expect(prop.appends_at_paths).to eq(%w[data items]) + expect(prop.prepends_at_paths).to eq([]) + expect(prop.match_on).to be_nil + end + + it 'supports prepend with nested merge paths as array' do + prop = described_class.new(prepend: %w[data items]) { [] } + + expect(prop.appends_at_root?).to be false + expect(prop.prepends_at_root?).to be false + expect(prop.appends_at_paths).to eq([]) + expect(prop.prepends_at_paths).to eq(%w[data items]) + expect(prop.match_on).to be_nil + end + + it 'supports complex mix of append and prepend with nested merge paths and match_on' do + prop = described_class.new( + append: { + data: nil, + users: 'id', + posts: nil, + }, + prepend: { + categories: nil, + companies: :id, + comments: nil, + }, + match_on: %w[comments.key] + ) { [] } + + expect(prop.appends_at_root?).to be false + expect(prop.prepends_at_root?).to be false + expect(prop.appends_at_paths).to match_array(%w[data users posts]) + expect(prop.prepends_at_paths).to match_array(%w[categories companies comments]) + expect(prop.match_on).to match_array(%w[comments.key users.id companies.id]) + end + end end diff --git a/spec/inertia/prop_mergeable_spec.rb b/spec/inertia/prop_mergeable_spec.rb new file mode 100644 index 00000000..c62d0974 --- /dev/null +++ b/spec/inertia/prop_mergeable_spec.rb @@ -0,0 +1,388 @@ +# frozen_string_literal: true + +RSpec.describe InertiaRails::PropMergeable do + let(:test_class) do + Class.new(InertiaRails::BaseProp) do + prepend InertiaRails::PropMergeable + + def initialize(**_props, &block) + super(&block) + end + end + end + + describe '#initialize' do + it 'raises ArgumentError when both deep_merge and merge are true' do + expect do + test_class.new(deep_merge: true, merge: true) + end.to raise_error(ArgumentError, 'Cannot set both `deep_merge` and `merge` to true') + end + + context 'default values' do + it 'sets default values correctly' do + instance = test_class.new + + expect(instance.deep_merge?).to be false + expect(instance.merge?).to be false + expect(instance.match_on).to be_nil + expect(instance.appends_at_paths).to eq([]) + expect(instance.prepends_at_paths).to eq([]) + end + + it 'sets merge to true when deep_merge is true' do + instance = test_class.new(deep_merge: true) + + expect(instance.deep_merge?).to be true + expect(instance.merge?).to be true + end + end + + context 'match_on handling' do + it 'converts string match_on to array' do + instance = test_class.new(match_on: 'id') + + expect(instance.match_on).to eq(['id']) + end + + it 'keeps array match_on as array' do + instance = test_class.new(match_on: %w[id slug]) + + expect(instance.match_on).to eq(%w[id slug]) + end + + it 'handles nil match_on' do + instance = test_class.new(match_on: nil) + + expect(instance.match_on).to be_nil + end + end + + context 'append handling' do + context 'with boolean values' do + it 'sets append flag to true' do + instance = test_class.new(merge: true, append: true) + + expect(instance.appends_at_root?).to be true + expect(instance.prepends_at_root?).to be false + end + + it 'sets append flag to false when merge is false' do + instance = test_class.new(merge: false, append: true) + + expect(instance.appends_at_root?).to be false + expect(instance.prepends_at_root?).to be false + end + + it 'sets append flag to false' do + instance = test_class.new(merge: true, append: false) + + expect(instance.appends_at_root?).to be false + expect(instance.prepends_at_root?).to be true + end + end + + context 'with string paths' do + it 'adds string path to appends_at_paths' do + instance = test_class.new(merge: true, append: 'items') + + expect(instance.appends_at_paths).to include('items') + expect(instance.prepends_at_paths).to be_empty + end + + it 'adds match_on when provided' do + instance = test_class.new(merge: true, append: { 'items' => nil, 'products' => 'id' }) + + expect(instance.match_on).to match_array(['products.id']) + end + end + + context 'with array paths' do + it 'adds all paths from array to appends_at_paths' do + instance = test_class.new(merge: true, append: %w[items products]) + + expect(instance.appends_at_paths).to include('items', 'products') + expect(instance.prepends_at_paths).to be_empty + end + end + + context 'with hash paths' do + it 'adds keys as paths and creates match_on patterns' do + instance = test_class.new(merge: true, append: { items: 'id', products: 'slug' }) + + expect(instance.appends_at_paths).to include('items', 'products') + expect(instance.match_on).to include('items.id', 'products.slug') + end + + it 'handles hash with nil values' do + instance = test_class.new(merge: true, append: { items: nil, products: 'slug' }) + + expect(instance.appends_at_paths).to include('items', 'products') + expect(instance.match_on).to include('products.slug') + expect(instance.match_on).not_to include('items.') + end + + it 'initializes match_on when not previously set' do + instance = test_class.new + instance.send(:append, { items: 'id' }) + + expect(instance.match_on).to include('items.id') + end + end + end + + context 'prepend handling' do + context 'with boolean values' do + it 'sets append flag to false when prepend is true' do + instance = test_class.new(merge: true, prepend: true) + + expect(instance.appends_at_root?).to be false + expect(instance.prepends_at_root?).to be true + end + + it 'sets append flag to true when prepend is false' do + instance = test_class.new(merge: true, prepend: false) + + expect(instance.appends_at_root?).to be true + expect(instance.prepends_at_root?).to be false + end + end + + context 'with string paths' do + it 'adds string path to prepends_at_paths' do + instance = test_class.new(merge: true, prepend: 'items') + + expect(instance.prepends_at_paths).to include('items') + expect(instance.appends_at_paths).to be_empty + end + + it 'adds match_on when provided' do + instance = test_class.new(merge: true, prepend: 'items') + instance.send(:prepend, 'products', match_on: 'id') + + expect(instance.match_on).to include('products.id') + end + end + + context 'with array paths' do + it 'adds all paths from array to prepends_at_paths' do + instance = test_class.new(merge: true, prepend: %w[items products]) + + expect(instance.prepends_at_paths).to include('items', 'products') + expect(instance.appends_at_paths).to be_empty + end + end + + context 'with hash paths' do + it 'adds keys as paths and creates match_on patterns' do + instance = test_class.new(merge: true, prepend: { items: 'id', products: 'slug' }) + + expect(instance.prepends_at_paths).to include('items', 'products') + expect(instance.match_on).to include('items.id', 'products.slug') + end + + it 'handles hash with nil values' do + instance = test_class.new(merge: true, prepend: { items: nil, products: 'slug' }) + + expect(instance.prepends_at_paths).to include('items', 'products') + expect(instance.match_on).to include('products.slug') + expect(instance.match_on).not_to include('items.') + end + end + end + end + describe 'state query methods' do + describe '#merges_at_root?' do + it 'returns false when merge is false' do + instance = test_class.new(merge: false, append: 'items') + + expect(instance.merges_at_root?).to be false + end + + it 'returns true when no paths are configured and merge is true' do + instance = test_class.new(merge: true) + + expect(instance.merges_at_root?).to be true + end + + it 'returns false when append paths are configured' do + instance = test_class.new(merge: true, append: 'items') + + expect(instance.merges_at_root?).to be false + end + + it 'returns false when prepend paths are configured' do + instance = test_class.new(merge: true, prepend: 'items') + + expect(instance.merges_at_root?).to be false + end + + it 'returns false when both append and prepend paths are configured' do + instance = test_class.new(merge: true, append: 'items', prepend: 'products') + + expect(instance.merges_at_root?).to be false + end + end + + describe '#appends_at_root?' do + it 'returns false when merge? is false' do + instance = test_class.new(merge: false) + + expect(instance.appends_at_root?).to be false + end + + it 'returns false when merge? is true but paths are configured' do + instance = test_class.new(merge: true, append: 'items') + + expect(instance.appends_at_root?).to be false + end + + it 'returns true when merge? is true and no paths configured and append is true' do + instance = test_class.new(merge: true, append: true) + + expect(instance.appends_at_root?).to be true + end + + it 'returns false when append is explicitly false' do + instance = test_class.new(merge: true, append: false) + + expect(instance.appends_at_root?).to be false + end + end + + describe '#prepends_at_root?' do + it 'returns false when merge? is false' do + instance = test_class.new(merge: false) + + expect(instance.prepends_at_root?).to be false + end + + it 'returns false when merge? is true but paths are configured' do + instance = test_class.new(merge: true, prepend: 'items') + + expect(instance.prepends_at_root?).to be false + end + + it 'returns true when merge? is true and no paths configured and append is false' do + instance = test_class.new(merge: true, append: false) + + expect(instance.prepends_at_root?).to be true + end + + it 'returns false when prepend sets append to true' do + instance = test_class.new(merge: true, prepend: false) + + expect(instance.prepends_at_root?).to be false + end + end + end + + describe 'complex path handling' do + it 'handles mixed append/prepend configurations' do + instance = test_class.new( + merge: true, + append: %w[items categories], + prepend: 'featured_products' + ) + + expect(instance.appends_at_paths).to include('items', 'categories') + expect(instance.prepends_at_paths).to include('featured_products') + expect(instance.merges_at_root?).to be false + end + + it 'handles nested path configurations with match_on' do + instance = test_class.new( + append: { 'users.posts' => 'id', 'users.comments' => 'created_at' } + ) + + expect(instance.appends_at_paths).to include('users.posts', 'users.comments') + expect(instance.match_on).to include('users.posts.id', 'users.comments.created_at') + end + + it 'accumulates match_on patterns from multiple configurations' do + instance = test_class.new( + match_on: ['existing'], + append: { items: 'id' }, + prepend: { products: 'slug' } + ) + + expect(instance.match_on).to include('existing', 'items.id', 'products.slug') + end + end + + describe 'string interpolation for match_on patterns' do + it 'correctly formats path.match_on patterns' do + instance = test_class.new(merge: true, append: { 'users.posts' => 'id' }) + + expect(instance.match_on).to include('users.posts.id') + end + + it 'handles complex nested paths' do + instance = test_class.new( + append: { 'api.v1.users.posts' => 'uuid' } + ) + + expect(instance.match_on).to include('api.v1.users.posts.uuid') + end + + it 'handles special characters in paths' do + instance = test_class.new( + append: { 'user-data_items' => 'item-id' } + ) + + expect(instance.match_on).to include('user-data_items.item-id') + end + end + + describe 'edge cases' do + it 'handles empty arrays' do + instance = test_class.new(merge: true, append: [], prepend: []) + + expect(instance.appends_at_paths).to be_empty + expect(instance.prepends_at_paths).to be_empty + expect(instance.merges_at_root?).to be true + end + + it 'handles empty hashes' do + instance = test_class.new(merge: true, append: {}, prepend: {}) + + expect(instance.appends_at_paths).to be_empty + expect(instance.prepends_at_paths).to be_empty + expect(instance.match_on).to be_empty + end + + it 'handles numeric paths converted to strings' do + instance = test_class.new(merge: true, append: { 123 => 'id' }) + + expect(instance.appends_at_paths).to include('123') + expect(instance.match_on).to include('123.id') + end + + it 'handles symbol paths converted to strings' do + instance = test_class.new(merge: true, append: { items: :id }) + + expect(instance.appends_at_paths).to include('items') + expect(instance.match_on).to include('items.id') + end + end + + describe 'interaction with superclass' do + let(:test_class_with_super) do + Class.new do + include InertiaRails::PropMergeable + + attr_reader :super_called + + def initialize(**props, &block) + @super_called = true + super + end + end + end + + it 'calls super in initialization' do + instance = test_class_with_super.new + + expect(instance.super_called).to be true + end + end +end diff --git a/spec/inertia/rendering_spec.rb b/spec/inertia/rendering_spec.rb index c75a929b..67a1d09a 100644 --- a/spec/inertia/rendering_spec.rb +++ b/spec/inertia/rendering_spec.rb @@ -626,6 +626,96 @@ end end + context 'scroll props rendering' do + let(:headers) { { 'X-Inertia' => true } } + + before do + # Create a mock controller action that returns scroll props + get '/scroll_test', headers: headers + end + + it 'includes scroll props metadata in response without reset' do + expect(response).to be_successful + expect(response.parsed_body['scrollProps']).to include('users') + expect(response.parsed_body['scrollProps']['users']).to include( + 'pageName' => 'page', + 'currentPage' => 1, + 'nextPage' => 2, + 'previousPage' => nil, + 'reset' => false + ) + end + + context 'with reset header' do + let(:headers) do + { + 'X-Inertia' => true, + 'X-Inertia-Reset' => 'users', + } + end + + it 'marks scroll props as reset' do + expect(response).to be_successful + expect(response.parsed_body['scrollProps']['users']['reset']).to be true + end + end + end + + context 'prepend merge props rendering' do + let(:headers) { { 'X-Inertia' => true } } + + before do + get '/prepend_merge_test', headers: headers + end + + it 'includes prepend props in response' do + expect(response).to be_successful + expect(response.parsed_body['prependProps']).to include('prepend_prop') + expect(response.parsed_body['mergeProps']).to include('append_prop') + end + end + + context 'nested paths merge props rendering' do + let(:headers) { { 'X-Inertia' => true } } + + before do + get '/nested_paths_test', headers: headers + end + + it 'includes nested paths in merge props' do + expect(response).to be_successful + expect(response.parsed_body['mergeProps']).to include('foo.data') + expect(response.parsed_body['prependProps']).to include('bar.data.items') + end + + it 'includes match strategies for nested paths' do + expect(response).to be_successful + expect(response.parsed_body['matchPropsOn']).to include('foo.data.id') + expect(response.parsed_body['matchPropsOn']).to include('bar.data.items.uuid') + end + end + + context 'enhanced reset header handling' do + let(:headers) do + { + 'X-Inertia' => true, + 'X-Inertia-Partial-Data' => 'merge_prop', + 'X-Inertia-Partial-Component' => 'TestComponent', + 'X-Inertia-Reset' => 'merge_prop', + } + end + + before do + get '/reset_test', headers: headers + end + + it 'excludes reset merge props from mergeProps array' do + expect(response).to be_successful + expect(response.parsed_body['props']).to include('merge_prop') + expect(response.parsed_body['mergeProps']).to be_nil + end + end + context 'deferred prop rendering' do context 'on first load' do let(:headers) { { 'X-Inertia' => true } } diff --git a/spec/inertia/scroll_metadata_spec.rb b/spec/inertia/scroll_metadata_spec.rb new file mode 100644 index 00000000..fb109505 --- /dev/null +++ b/spec/inertia/scroll_metadata_spec.rb @@ -0,0 +1,372 @@ +# frozen_string_literal: true + +RSpec.describe InertiaRails::ScrollMetadata do + describe '.extract' do + context 'with Kaminari adapter' do + before do + stub_const('Kaminari', Class.new) + stub_const('Kaminari::PageScopeMethods', Module.new) + allow(Kaminari).to receive(:config).and_return(double(param_name: 'page')) + end + + let(:kaminari_metadata) do + instance_double('KaminariPage').tap do |metadata| + metadata.extend(Kaminari::PageScopeMethods) + allow(metadata).to receive(:prev_page).and_return(1) + allow(metadata).to receive(:next_page).and_return(3) + allow(metadata).to receive(:current_page).and_return(2) + end + end + + it 'extracts metadata from Kaminari paginator' do + result = described_class.extract(kaminari_metadata) + + expect(result).to eq( + pageName: 'page', + previousPage: 1, + nextPage: 3, + currentPage: 2 + ) + end + + it 'handles nil param_name in Kaminari config' do + allow(Kaminari.config).to receive(:param_name).and_return(nil) + + result = described_class.extract(kaminari_metadata) + + expect(result).to eq( + pageName: 'page', + previousPage: 1, + nextPage: 3, + currentPage: 2 + ) + end + + it 'allows options to override metadata values' do + result = described_class.extract( + kaminari_metadata, + page_name: 'custom_page', + current_page: 5 + ) + + expect(result).to eq( + pageName: 'custom_page', + previousPage: 1, + nextPage: 3, + currentPage: 5 + ) + end + end + + context 'with Pagy adapter' do + before do + stub_const('Pagy', Class.new) + end + + let(:pagy_metadata) do + instance_double('Pagy').tap do |metadata| + allow(metadata).to receive(:is_a?).and_return(false) + allow(metadata).to receive(:is_a?).with(Pagy).and_return(true) + allow(metadata).to receive(:vars).and_return({ page_param: :page }) + allow(metadata).to receive(:prev).and_return(1) + allow(metadata).to receive(:next).and_return(3) + allow(metadata).to receive(:page).and_return(2) + end + end + + it 'extracts metadata from Pagy paginator' do + result = described_class.extract(pagy_metadata) + + expect(result).to eq( + pageName: 'page', + previousPage: 1, + nextPage: 3, + currentPage: 2 + ) + end + + it 'raises error when page_param is missing from vars' do + allow(pagy_metadata).to receive(:vars).and_return({}) + + expect do + described_class.extract(pagy_metadata) + end.to raise_error(KeyError) + end + + it 'allows options to override metadata values' do + result = described_class.extract( + pagy_metadata, + page_name: 'items_page', + previous_page: 0 + ) + + expect(result).to eq( + pageName: 'items_page', + previousPage: 0, + nextPage: 3, + currentPage: 2 + ) + end + end + + context 'with Hash adapter' do + let(:hash_metadata) do + { + page_name: 'items', + previous_page: 1, + next_page: 3, + current_page: 2, + } + end + + it 'extracts metadata from hash' do + result = described_class.extract(hash_metadata) + + expect(result).to eq( + pageName: 'items', + previousPage: 1, + nextPage: 3, + currentPage: 2 + ) + end + + it 'raises error when required keys are missing' do + incomplete_hash = { page_name: 'items' } + + expect do + described_class.extract(incomplete_hash) + end.to raise_error(KeyError) + end + + it 'allows options to override hash values' do + result = described_class.extract( + hash_metadata, + page_name: 'overridden', + next_page: 5 + ) + + expect(result).to eq( + pageName: 'overridden', + previousPage: 1, + nextPage: 5, + currentPage: 2 + ) + end + end + + context 'with unsupported metadata type' do + it 'raises MissingMetadataAdapterError with no options provided' do + unsupported_metadata = 'unsupported' + + expect do + described_class.extract(unsupported_metadata) + end.to raise_error( + InertiaRails::ScrollMetadata::MissingMetadataAdapterError, + 'No ScrollMetadata adapter found for unsupported' + ) + end + + it 'uses options as fallback when no adapter matches' do + unsupported_metadata = 'unsupported' + + result = described_class.extract( + unsupported_metadata, + page_name: 'fallback', + previous_page: nil, + next_page: nil, + current_page: 1 + ) + + expect(result).to eq( + pageName: 'fallback', + previousPage: nil, + nextPage: nil, + currentPage: 1 + ) + end + + it 'raises error when insufficient options provided for unsupported type' do + unsupported_metadata = 'unsupported' + + expect do + described_class.extract(unsupported_metadata, page_name: 'fallback') + end.to raise_error( + InertiaRails::ScrollMetadata::MissingMetadataAdapterError, + 'No ScrollMetadata adapter found for unsupported' + ) + end + end + + context 'with nil metadata' do + it 'uses options to create props when all required options provided' do + result = described_class.extract( + nil, + page_name: 'nil_page', + previous_page: nil, + next_page: 2, + current_page: 1 + ) + + expect(result).to eq( + pageName: 'nil_page', + previousPage: nil, + nextPage: 2, + currentPage: 1 + ) + end + + it 'raises error when insufficient options provided for nil metadata' do + expect do + described_class.extract(nil, page_name: 'partial') + end.to raise_error( + InertiaRails::ScrollMetadata::MissingMetadataAdapterError, + 'No ScrollMetadata adapter found for ' + ) + end + end + end + + describe '.register_adapter' do + after do + # Reset adapters to original state + described_class.adapters = [ + InertiaRails::ScrollMetadata::KaminariAdapter, + InertiaRails::ScrollMetadata::PagyAdapter, + InertiaRails::ScrollMetadata::HashAdapter + ].map(&:new) + end + + it 'registers custom adapter and gives it priority' do + custom_adapter_class = Class.new do + def match?(metadata) + metadata == 'custom' + end + + def call(_metadata, **_options) + { + page_name: 'custom_adapter', + previous_page: nil, + next_page: nil, + current_page: 1, + } + end + end + + described_class.register_adapter(custom_adapter_class) + + result = described_class.extract('custom') + + expect(result).to eq( + pageName: 'custom_adapter', + previousPage: nil, + nextPage: nil, + currentPage: 1 + ) + end + + it 'gives precedence to most recently registered adapters' do + first_adapter = Class.new do + def match?(metadata) + metadata.is_a?(Hash) + end + + def call(_metadata, **_options) + { + page_name: 'first_adapter', + previous_page: nil, + next_page: nil, + current_page: 1, + } + end + end + + second_adapter = Class.new do + def match?(metadata) + metadata.is_a?(Hash) + end + + def call(_metadata, **_options) + { + page_name: 'second_adapter', + previous_page: nil, + next_page: nil, + current_page: 1, + } + end + end + + described_class.register_adapter(first_adapter) + described_class.register_adapter(second_adapter) + + result = described_class.extract({}) + + expect(result[:pageName]).to eq('second_adapter') + end + end + + describe InertiaRails::ScrollMetadata::Props do + describe '#as_json' do + it 'converts to proper JSON format' do + props = described_class.new( + page_name: 'items', + previous_page: 1, + next_page: 3, + current_page: 2 + ) + + result = props.as_json + + expect(result).to eq( + pageName: 'items', + previousPage: 1, + nextPage: 3, + currentPage: 2 + ) + end + + it 'ignores options parameter' do + props = described_class.new( + page_name: 'items', + previous_page: 1, + next_page: 3, + current_page: 2 + ) + + result = props.as_json({ some: 'options' }) + + expect(result).to eq( + pageName: 'items', + previousPage: 1, + nextPage: 3, + currentPage: 2 + ) + end + end + end + + describe 'adapter precedence' do + it 'tries adapters in registration order' do + # Mock all adapters to match + allow_any_instance_of(InertiaRails::ScrollMetadata::KaminariAdapter) + .to receive(:match?).and_return(true) + allow_any_instance_of(InertiaRails::ScrollMetadata::PagyAdapter) + .to receive(:match?).and_return(true) + allow_any_instance_of(InertiaRails::ScrollMetadata::HashAdapter) + .to receive(:match?).and_return(true) + + # Mock calls to return identifiable results + allow_any_instance_of(InertiaRails::ScrollMetadata::KaminariAdapter) + .to receive(:call).and_return({ + page_name: 'kaminari', + previous_page: nil, + next_page: nil, + current_page: 1, + }) + + result = described_class.extract('test') + + # Should use Kaminari adapter (first in the list) + expect(result[:pageName]).to eq('kaminari') + end + end +end diff --git a/spec/inertia/scroll_prop_spec.rb b/spec/inertia/scroll_prop_spec.rb new file mode 100644 index 00000000..b8cc8a76 --- /dev/null +++ b/spec/inertia/scroll_prop_spec.rb @@ -0,0 +1,343 @@ +# frozen_string_literal: true + +RSpec.describe InertiaRails::ScrollProp do + it_behaves_like 'base prop' + + describe '#metadata' do + it 'resolves metadata from Pagy paginator' do + collection = Array.new(100) { |i| "item#{i}" } + pagy, = Pagy.new(count: collection.size, page: 1, items: 20) + + prop = described_class.new(metadata: pagy) { collection } + metadata = prop.metadata + + expect(metadata).to eq( + pageName: 'page', + previousPage: nil, + nextPage: 2, + currentPage: 1 + ) + end + + it 'resolves metadata from Kaminari paginator' do + collection = Array.new(100) { |i| "item#{i}" } + collection = Kaminari.paginate_array(collection).page(1).per(20) + + prop = described_class.new(metadata: collection) { collection } + metadata = prop.metadata + + another_prop = described_class.new(metadata: collection, page_name: 'another_pagination') { collection } + another_metadata = another_prop.metadata + + expect(metadata).to eq( + pageName: 'page', + previousPage: nil, + nextPage: 2, + currentPage: 1 + ) + expect(another_metadata).to eq( + pageName: 'another_pagination', + previousPage: nil, + nextPage: 2, + currentPage: 1 + ) + end + + it 'resolves custom metadata from provider hash' do + metadata = { + page_name: 'custom_page', + previous_page: 1, + next_page: 3, + current_page: 2, + } + + prop = described_class.new(metadata: metadata) { %w[item1 item2] } + metadata = prop.metadata + + expect(metadata).to eq( + pageName: 'custom_page', + previousPage: 1, + nextPage: 3, + currentPage: 2 + ) + end + end + + describe '#configure_merge_intent' do + let(:headers) { {} } + let(:controller) do + controller = double('Controller') + request = double('Request') + + allow(controller).to receive(:request).and_return(request) + allow(request).to receive(:headers).and_return(headers) + controller + end + + it 'defaults to appending when no header is present' do + prop = described_class.new(wrapper: 'data') { %w[item1 item2] } + prop.call(controller) + + expect(prop.appends_at_paths).to include('data') + expect(prop.prepends_at_paths).to be_empty + end + + context 'when merge intent header is "append"' do + let(:headers) { { 'X-Inertia-Infinite-Scroll-Merge-Intent' => 'append' } } + + it 'appends' do + prop = described_class.new(wrapper: 'data') { %w[item1 item2] } + prop.call(controller) + + expect(prop.appends_at_paths).to include('data') + expect(prop.prepends_at_paths).to be_empty + end + end + + context 'when merge intent header is "prepend"' do + let(:headers) { { 'X-Inertia-Infinite-Scroll-Merge-Intent' => 'prepend' } } + + it 'prepends at root' do + prop = described_class.new { %w[item1 item2] } + prop.call(controller) + + expect(prop.prepends_at_paths).to be_empty + expect(prop.appends_at_paths).to be_empty + expect(prop.appends_at_root?).to be false + expect(prop.prepends_at_root?).to be true + end + end + + context 'with custom wrapper key' do + let(:headers) { { 'X-Inertia-Infinite-Scroll-Merge-Intent' => 'prepend' } } + + it 'prepends to the wrapper key' do + prop = described_class.new(wrapper: 'items') { %w[item1 item2] } + prop.call(controller) + + expect(prop.prepends_at_paths).to include('items') + expect(prop.appends_at_paths).to be_empty + expect(prop.appends_at_root?).to be false + expect(prop.prepends_at_root?).to be false + end + end + end + + describe '#merge?' do + it 'always returns true' do + prop = described_class.new { %w[item1 item2] } + expect(prop.merge?).to be true + end + end + + describe 'edge cases' do + let(:headers) { {} } + let(:controller) do + controller = double('Controller') + request = double('Request') + + allow(controller).to receive(:request).and_return(request) + allow(request).to receive(:headers).and_return(headers) + controller + end + + context 'with nil wrapper handling' do + it 'does not configure merge intent when wrapper is nil' do + prop = described_class.new(wrapper: nil) { %w[item1 item2] } + prop.call(controller) + + expect(prop.appends_at_paths).to be_empty + expect(prop.prepends_at_paths).to be_empty + end + end + + context 'with invalid metadata types' do + it 'raises MissingMetadataAdapterError for unsupported metadata' do + prop = described_class.new(metadata: 'unsupported_type') { %w[item1 item2] } + + expect do + prop.metadata + end.to raise_error( + InertiaRails::ScrollMetadata::MissingMetadataAdapterError, + 'No ScrollMetadata adapter found for unsupported_type' + ) + end + + it 'raises MissingMetadataAdapterError for custom objects' do + custom_object = Class.new.new + prop = described_class.new(metadata: custom_object) { %w[item1 item2] } + + expect do + prop.metadata + end.to raise_error( + InertiaRails::ScrollMetadata::MissingMetadataAdapterError + ) + end + + it 'uses options as fallback when metadata is unsupported' do + prop = described_class.new( + metadata: 'unsupported', + page_name: 'fallback', + previous_page: nil, + next_page: 2, + current_page: 1 + ) { %w[item1 item2] } + + result = prop.metadata + + expect(result).to eq( + pageName: 'fallback', + previousPage: nil, + nextPage: 2, + currentPage: 1 + ) + end + end + + context 'with malformed headers' do + it 'defaults to append with unexpected header value' do + headers = { 'X-Inertia-Infinite-Scroll-Merge-Intent' => 'invalid_value' } + allow(controller.request).to receive(:headers).and_return(headers) + + prop = described_class.new(wrapper: 'data') { %w[item1 item2] } + prop.call(controller) + + expect(prop.appends_at_paths).to include('data') + expect(prop.prepends_at_paths).to be_empty + end + + it 'defaults to append with empty header value' do + headers = { 'X-Inertia-Infinite-Scroll-Merge-Intent' => '' } + allow(controller.request).to receive(:headers).and_return(headers) + + prop = described_class.new(wrapper: 'data') { %w[item1 item2] } + prop.call(controller) + + expect(prop.appends_at_paths).to include('data') + expect(prop.prepends_at_paths).to be_empty + end + + it 'defaults to append with nil header value' do + headers = { 'X-Inertia-Infinite-Scroll-Merge-Intent' => nil } + allow(controller.request).to receive(:headers).and_return(headers) + + prop = described_class.new(wrapper: 'data') { %w[item1 item2] } + prop.call(controller) + + expect(prop.appends_at_paths).to include('data') + expect(prop.prepends_at_paths).to be_empty + end + + it 'handles case sensitivity correctly' do + headers = { 'X-Inertia-Infinite-Scroll-Merge-Intent' => 'PREPEND' } + allow(controller.request).to receive(:headers).and_return(headers) + + prop = described_class.new(wrapper: 'data') { %w[item1 item2] } + prop.call(controller) + + expect(prop.appends_at_paths).to include('data') + expect(prop.prepends_at_paths).to be_empty + end + + it 'handles prepend with correct case' do + headers = { 'X-Inertia-Infinite-Scroll-Merge-Intent' => 'prepend' } + allow(controller.request).to receive(:headers).and_return(headers) + + prop = described_class.new(wrapper: 'data') { %w[item1 item2] } + prop.call(controller) + + expect(prop.prepends_at_paths).to include('data') + expect(prop.appends_at_paths).to be_empty + end + end + + context 'with metadata options precedence' do + let(:hash_metadata) do + { + page_name: 'original', + previous_page: 1, + next_page: 3, + current_page: 2, + } + end + + it 'allows page_name override' do + prop = described_class.new( + metadata: hash_metadata, + page_name: 'overridden' + ) { %w[item1 item2] } + + result = prop.metadata + + expect(result[:pageName]).to eq('overridden') + expect(result[:currentPage]).to eq(2) + end + + it 'allows multiple options override' do + prop = described_class.new( + metadata: hash_metadata, + page_name: 'custom_page', + current_page: 5, + next_page: 6 + ) { %w[item1 item2] } + + result = prop.metadata + + expect(result).to eq( + pageName: 'custom_page', + previousPage: 1, + nextPage: 6, + currentPage: 5 + ) + end + + it 'preserves original metadata when no options provided' do + prop = described_class.new(metadata: hash_metadata) { %w[item1 item2] } + + result = prop.metadata + + expect(result).to eq( + pageName: 'original', + previousPage: 1, + nextPage: 3, + currentPage: 2 + ) + end + end + + context 'without metadata' do + it 'handles nil metadata with options' do + prop = described_class.new( + metadata: nil, + page_name: 'no_metadata', + previous_page: nil, + next_page: 2, + current_page: 1 + ) { %w[item1 item2] } + + result = prop.metadata + + expect(result).to eq( + pageName: 'no_metadata', + previousPage: nil, + nextPage: 2, + currentPage: 1 + ) + end + + it 'raises error when metadata is nil and insufficient options' do + prop = described_class.new( + metadata: nil, + page_name: 'incomplete' + ) { %w[item1 item2] } + + expect do + prop.metadata + end.to raise_error( + InertiaRails::ScrollMetadata::MissingMetadataAdapterError, + 'No ScrollMetadata adapter found for ' + ) + end + end + end +end diff --git a/spec/support/shared_examples.rb b/spec/support/shared_examples.rb index 10921fda..cd6dfcea 100644 --- a/spec/support/shared_examples.rb +++ b/spec/support/shared_examples.rb @@ -4,7 +4,15 @@ describe '#call' do subject(:call) { prop.call(controller) } let(:prop) { described_class.new { 'block' } } - let(:controller) { ApplicationController.new } + let(:headers) { {} } + let(:controller) do + controller = ApplicationController.new + request = double('Request') + + allow(controller).to receive(:request).and_return(request) + allow(request).to receive(:headers).and_return(headers) + controller + end it { is_expected.to eq('block') } From 2a78218597ff884fd005a8a5fe59b9c57051983a Mon Sep 17 00:00:00 2001 From: Svyatoslav Kryukov Date: Fri, 7 Nov 2025 00:32:56 +0300 Subject: [PATCH 6/6] Add support for Pagy v43 --- .github/workflows/push.yml | 10 ++++++---- Gemfile | 2 +- docs/guide/infinite-scroll.md | 10 +++++----- docs/guide/merging-props.md | 8 ++++---- lib/inertia_rails/scroll_metadata.rb | 5 +++-- .../inertia_render_test_controller.rb | 4 +--- spec/inertia/scroll_metadata_spec.rb | 18 +++--------------- spec/inertia/scroll_prop_spec.rb | 4 +++- 8 files changed, 26 insertions(+), 35 deletions(-) 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 f102bf2e..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' diff --git a/docs/guide/infinite-scroll.md b/docs/guide/infinite-scroll.md index f7e3c497..40a22dde 100644 --- a/docs/guide/infinite-scroll.md +++ b/docs/guide/infinite-scroll.md @@ -14,10 +14,10 @@ To configure your paginated data for infinite scrolling, you should use the `Ine ```ruby class UsersController < ApplicationController - include Pagy::Backend + include Pagy::Method def index - pagy, records = pagy(User.all) + pagy, records = pagy(:countless, User.all) render inertia: { users: InertiaRails.scroll(pagy) { records.as_json(...) } @@ -1112,11 +1112,11 @@ Sometimes you may need to render multiple infinite scroll components on a single ```ruby class DashboardController < ApplicationController - include Pagy::Backend + include Pagy::Method def index - pagy_users, users = pagy(User.all, page_param: :users) - pagy_orders, orders = pagy(Order.all, page_param: :orders) + 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(...) }, diff --git a/docs/guide/merging-props.md b/docs/guide/merging-props.md index 283749b3..2824c382 100644 --- a/docs/guide/merging-props.md +++ b/docs/guide/merging-props.md @@ -12,10 +12,10 @@ To merge a prop instead of overwriting it, you may use the `InertiaRails.merge` ```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: { users: InertiaRails.merge { records.as_json(...) }, @@ -133,10 +133,10 @@ You can also combine [deferred props](/guide/deferred-props) with mergeable prop ```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: { results: InertiaRails.defer(deep_merge: true) { records.as_json(...) }, diff --git a/lib/inertia_rails/scroll_metadata.rb b/lib/inertia_rails/scroll_metadata.rb index 86091442..f19665d6 100644 --- a/lib/inertia_rails/scroll_metadata.rb +++ b/lib/inertia_rails/scroll_metadata.rb @@ -43,9 +43,10 @@ def match?(metadata) end def call(metadata, **_options) + page_name = metadata.respond_to?(:vars) ? metadata.vars.fetch(:page_param) : metadata.options[:page_key] { - page_name: metadata.vars.fetch(:page_param).to_s, - previous_page: metadata.prev, + page_name: page_name.to_s, + previous_page: metadata.try(:prev) || metadata.try(:previous), next_page: metadata.next, current_page: metadata.page, } diff --git a/spec/dummy/app/controllers/inertia_render_test_controller.rb b/spec/dummy/app/controllers/inertia_render_test_controller.rb index 0c256b0c..b78d9e26 100644 --- a/spec/dummy/app/controllers/inertia_render_test_controller.rb +++ b/spec/dummy/app/controllers/inertia_render_test_controller.rb @@ -130,9 +130,7 @@ def deferred_props end def scroll_test - pagy = Pagy.new( - vars: { page_param: 'page' }, - prev: nil, + pagy = (defined?(Pagy::Offset) ? Pagy::Offset : Pagy).new( next: 2, page: 1, count: 100 diff --git a/spec/inertia/scroll_metadata_spec.rb b/spec/inertia/scroll_metadata_spec.rb index fb109505..982c4170 100644 --- a/spec/inertia/scroll_metadata_spec.rb +++ b/spec/inertia/scroll_metadata_spec.rb @@ -59,16 +59,12 @@ end context 'with Pagy adapter' do - before do - stub_const('Pagy', Class.new) - end - let(:pagy_metadata) do - instance_double('Pagy').tap do |metadata| + instance_double('Pagy::Offset').tap do |metadata| allow(metadata).to receive(:is_a?).and_return(false) allow(metadata).to receive(:is_a?).with(Pagy).and_return(true) - allow(metadata).to receive(:vars).and_return({ page_param: :page }) - allow(metadata).to receive(:prev).and_return(1) + allow(metadata).to receive(:options).and_return({ page_key: :page }) + allow(metadata).to receive(:previous).and_return(1) allow(metadata).to receive(:next).and_return(3) allow(metadata).to receive(:page).and_return(2) end @@ -85,14 +81,6 @@ ) end - it 'raises error when page_param is missing from vars' do - allow(pagy_metadata).to receive(:vars).and_return({}) - - expect do - described_class.extract(pagy_metadata) - end.to raise_error(KeyError) - end - it 'allows options to override metadata values' do result = described_class.extract( pagy_metadata, diff --git a/spec/inertia/scroll_prop_spec.rb b/spec/inertia/scroll_prop_spec.rb index b8cc8a76..98059f20 100644 --- a/spec/inertia/scroll_prop_spec.rb +++ b/spec/inertia/scroll_prop_spec.rb @@ -6,7 +6,9 @@ describe '#metadata' do it 'resolves metadata from Pagy paginator' do collection = Array.new(100) { |i| "item#{i}" } - pagy, = Pagy.new(count: collection.size, page: 1, items: 20) + pagy, = (defined?(Pagy::Offset) ? Pagy::Offset : Pagy).new( + count: collection.size, page: 1, items: 20 + ) prop = described_class.new(metadata: pagy) { collection } metadata = prop.metadata