Skip to content
Merged
142 changes: 101 additions & 41 deletions packages/core/src/response.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AxiosResponse } from 'axios'
import { get, set } from 'lodash-es'
import { router } from '.'
import { fireErrorEvent, fireInvalidEvent, firePrefetchedEvent, fireSuccessEvent } from './events'
import { history } from './history'
Expand Down Expand Up @@ -221,50 +222,60 @@ export class Response {
return
}

const propsToMerge = pageResponse.mergeProps || []
const propsToAppend = pageResponse.mergeProps || []
const propsToPrepend = pageResponse.prependProps || []
const propsToDeepMerge = pageResponse.deepMergeProps || []
const matchPropsOn = pageResponse.matchPropsOn || []

propsToMerge.forEach((prop) => {
const incomingProp = pageResponse.props[prop]
const mergeProp = (prop: string, shouldAppend: boolean) => {
const currentProp = get(currentPage.get().props, prop)
const incomingProp = get(pageResponse.props, prop)

if (Array.isArray(incomingProp)) {
pageResponse.props[prop] = this.mergeOrMatchItems(
(currentPage.get().props[prop] || []) as any[],
const newArray = this.mergeOrMatchItems(
(currentProp || []) as any[],
incomingProp,
prop,
matchPropsOn,
shouldAppend,
)

set(pageResponse.props, prop, newArray)
} else if (typeof incomingProp === 'object' && incomingProp !== null) {
pageResponse.props[prop] = {
...((currentPage.get().props[prop] || []) as Record<string, any>),
const newObject = {
...(currentProp || {}),
...incomingProp,
}

set(pageResponse.props, prop, newObject)
}
})
}

propsToAppend.forEach((prop) => mergeProp(prop, true))
propsToPrepend.forEach((prop) => mergeProp(prop, false))

propsToDeepMerge.forEach((prop) => {
const incomingProp = pageResponse.props[prop]
const currentProp = currentPage.get().props[prop]
const incomingProp = pageResponse.props[prop]

// Function to recursively merge objects and arrays
const deepMerge = (target: any, source: any, currentKey: string) => {
const deepMerge = (target: any, source: any, matchProp: string) => {
if (Array.isArray(source)) {
return this.mergeOrMatchItems(target, source, currentKey, matchPropsOn)
return this.mergeOrMatchItems(target, source, matchProp, matchPropsOn)
}

if (typeof source === 'object' && source !== null) {
// Merge objects by iterating over keys
return Object.keys(source).reduce(
(acc, key) => {
acc[key] = deepMerge(target ? target[key] : undefined, source[key], `${currentKey}.${key}`)
acc[key] = deepMerge(target ? target[key] : undefined, source[key], `${matchProp}.${key}`)
return acc
},
{ ...target },
)
}

// f the source is neither an array nor an object, simply return the it
// If the source is neither an array nor an object, simply return the it
return source
}

Expand All @@ -275,45 +286,94 @@ export class Response {
pageResponse.props = { ...currentPage.get().props, ...pageResponse.props }
}

protected mergeOrMatchItems(target: any[], source: any[], currentKey: string, matchPropsOn: string[]) {
// Determine if there's a specific key to match items.
// E.g.: matchPropsOn = ['posts.data.id'] and currentKey = 'posts.data' will match.
const matchOn = matchPropsOn.find((key) => {
const path = key.split('.').slice(0, -1).join('.')
return path === currentKey
protected mergeOrMatchItems(
existingItems: any[],
newItems: any[],
matchProp: string,
matchPropsOn: string[],
shouldAppend = true,
) {
const items = Array.isArray(existingItems) ? existingItems : []

// Find the matching key for this specific property path
const matchingKey = matchPropsOn.find((key) => {
const keyPath = key.split('.').slice(0, -1).join('.')

return keyPath === matchProp
})

if (!matchOn) {
// No key found to match on, just concatenate the arrays
return [...(Array.isArray(target) ? target : []), ...source]
// If no matching key is configured, simply concatenate the arrays
if (!matchingKey) {
return shouldAppend ? [...items, ...newItems] : [...newItems, ...items]
}

// Extract the unique property name to match items (e.g., 'id' from 'posts.data.id').
const uniqueProperty = matchOn.split('.').pop() || ''
const targetArray = Array.isArray(target) ? target : []
const map = new Map<any, any>()
// Extract the property name we'll use to match items (e.g., 'id' from 'users.data.id')
const uniqueProperty = matchingKey.split('.').pop() || ''

// Populate the map with items from the target array, using the unique property as the key.
// If an item doesn't have the unique property or isn't an object, a unique Symbol is used as the key.
targetArray.forEach((item) => {
if (item && typeof item === 'object' && uniqueProperty in item) {
map.set(item[uniqueProperty], item)
} else {
map.set(Symbol(), item)
// Create a map of new items by their unique property lookups
const newItemsMap = new Map()

newItems.forEach((item) => {
if (this.hasUniqueProperty(item, uniqueProperty)) {
newItemsMap.set(item[uniqueProperty], item)
}
})

// Iterate through the source array. If an item's unique property matches an existing key in the map,
// update the item. Otherwise, add the new item to the map.
source.forEach((item) => {
if (item && typeof item === 'object' && uniqueProperty in item) {
map.set(item[uniqueProperty], item)
} else {
map.set(Symbol(), item)
return shouldAppend
? this.appendWithMatching(items, newItems, newItemsMap, uniqueProperty)
: this.prependWithMatching(items, newItems, newItemsMap, uniqueProperty)
}

protected appendWithMatching(
existingItems: any[],
newItems: any[],
newItemsMap: Map<any, any>,
uniqueProperty: string,
): any[] {
// Update existing items with new values, keep non-matching items
const updatedExisting = existingItems.map((item) => {
if (this.hasUniqueProperty(item, uniqueProperty) && newItemsMap.has(item[uniqueProperty])) {
return newItemsMap.get(item[uniqueProperty])
}

return item
})

// Filter new items to only include those not already in existing items
const newItemsToAdd = newItems.filter((item) => {
if (!this.hasUniqueProperty(item, uniqueProperty)) {
return true // Always add items without unique property
}

return !existingItems.some(
(existing) =>
this.hasUniqueProperty(existing, uniqueProperty) && existing[uniqueProperty] === item[uniqueProperty],
)
})

return [...updatedExisting, ...newItemsToAdd]
}

protected prependWithMatching(
existingItems: any[],
newItems: any[],
newItemsMap: Map<any, any>,
uniqueProperty: string,
): any[] {
// Filter existing items, keeping only those not being updated
const untouchedExisting = existingItems.filter((item) => {
if (this.hasUniqueProperty(item, uniqueProperty)) {
return !newItemsMap.has(item[uniqueProperty])
}

return true
})

return Array.from(map.values())
return [...newItems, ...untouchedExisting]
}

protected hasUniqueProperty(item: any, property: string): boolean {
return item && typeof item === 'object' && property in item
}

protected async setRememberedState(pageResponse: Page): Promise<void> {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export interface Page<SharedProps extends PageProps = PageProps> {
encryptHistory: boolean
deferredProps?: Record<string, VisitOptions['only']>
mergeProps?: string[]
prependProps?: string[]
deepMergeProps?: string[]
matchPropsOn?: string[]

Expand Down
30 changes: 30 additions & 0 deletions packages/react/test-app/Pages/ComplexMergeSelective.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { router } from '@inertiajs/react'

export default ({
mixed,
}: {
mixed: {
name: string
users: string[]
chat: { data: number[] }
post: { id: number; comments: { allowed: boolean; data: string[] } }
}
}) => {
const reload = () => {
router.reload({
only: ['mixed'],
})
}

return (
<div>
<div>name is {mixed.name}</div>
<div>users: {mixed.users.join(', ')}</div>
<div>chat.data: {mixed.chat.data.join(', ')}</div>
<div>post.id: {mixed.post.id}</div>
<div>post.comments.allowed: {mixed.post.comments.allowed ? 'true' : 'false'}</div>
<div>post.comments.data: {mixed.post.comments.data.join(', ')}</div>
<button onClick={reload}>Reload</button>
</div>
)
}
24 changes: 24 additions & 0 deletions packages/react/test-app/Pages/MergeNestedProps.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { router } from '@inertiajs/react'

export default ({
users,
}: {
users: { data: { id: number; name: string }[]; meta: { page: number; perPage: number } }
}) => {
const loadMore = () => {
router.reload({
only: ['users'],
data: { page: users.meta.page + 1 },
})
}

return (
<div>
<p id="users">{users.data.map((user) => user.name).join(', ')}</p>
<p id="meta">
Page: {users.meta.page}, Per Page: {users.meta.perPage}
</p>
<button onClick={loadMore}>Load More</button>
</div>
)
}
24 changes: 24 additions & 0 deletions packages/svelte/test-app/Pages/ComplexMergeSelective.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<script lang="ts">
import { router } from '@inertiajs/svelte'

export let mixed: {
name: string
users: string[]
chat: { data: number[] }
post: { id: number; comments: { allowed: boolean; data: string[] } }
}

const reload = () => {
router.reload({
only: ['mixed'],
})
}
</script>

<div>name is {mixed.name}</div>
<div>users: {mixed.users.join(', ')}</div>
<div>chat.data: {mixed.chat.data.join(', ')}</div>
<div>post.id: {mixed.post.id}</div>
<div>post.comments.allowed: {mixed.post.comments.allowed ? 'true' : 'false'}</div>
<div>post.comments.data: {mixed.post.comments.data.join(', ')}</div>
<button on:click={reload}>Reload</button>
21 changes: 21 additions & 0 deletions packages/svelte/test-app/Pages/MergeNestedProps.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script lang="ts">
import { router } from '@inertiajs/svelte'

export let users: { data: { id: number; name: string }[]; meta: { page: number; perPage: number } } = {
data: [],
meta: { page: 1, perPage: 10 },
}

const loadMore = () => {
router.reload({
only: ['users'],
data: { page: users.meta.page + 1 },
})
}
</script>

<div>
<p id="users">{users.data.map((user) => user.name).join(', ')}</p>
<p id="meta">Page: {users.meta.page}, Per Page: {users.meta.perPage}</p>
<button on:click={loadMore}>Load More</button>
</div>
1 change: 1 addition & 0 deletions packages/vue3/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export function usePage<SharedProps extends PageProps>(): Page<SharedProps> {
clearHistory: computed(() => page.value?.clearHistory),
deferredProps: computed(() => page.value?.deferredProps),
mergeProps: computed(() => page.value?.mergeProps),
prependProps: computed(() => page.value?.prependProps),
deepMergeProps: computed(() => page.value?.deepMergeProps),
matchPropsOn: computed(() => page.value?.matchPropsOn),
rememberedState: computed(() => page.value?.rememberedState),
Expand Down
30 changes: 30 additions & 0 deletions packages/vue3/test-app/Pages/ComplexMergeSelective.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3'

defineProps<{
mixed: {
name: string
users: string[]
chat: { data: number[] }
post: { id: number; comments: { allowed: boolean; data: string[] } }
}
}>()

const reload = () => {
router.reload({
only: ['mixed'],
})
}
</script>

<template>
<div>
<div>name is {{ mixed.name }}</div>
<div>users: {{ mixed.users.join(', ') }}</div>
<div>chat.data: {{ mixed.chat.data.join(', ') }}</div>
<div>post.id: {{ mixed.post.id }}</div>
<div>post.comments.allowed: {{ mixed.post.comments.allowed ? 'true' : 'false' }}</div>
<div>post.comments.data: {{ mixed.post.comments.data.join(', ') }}</div>
<button @click="reload">Reload</button>
</div>
</template>
22 changes: 22 additions & 0 deletions packages/vue3/test-app/Pages/MergeNestedProps.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3'

const props = defineProps<{
users: { data: { id: number; name: string }[]; meta: { page: number; perPage: number } }
}>()

const loadMore = () => {
router.reload({
only: ['users'],
data: { page: props.users.meta.page + 1 },
})
}
</script>

<template>
<div>
<p id="users">{{ users.data.map((user) => user.name).join(', ') }}</p>
<p id="meta">Page: {{ users.meta.page }}, Per Page: {{ users.meta.perPage }}</p>
<button @click="loadMore">Load More</button>
</div>
</template>
Loading