Skip to content

Allow deepMerge on custom properties #2344

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 13, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 46 additions & 6 deletions packages/core/src/response.ts
Original file line number Diff line number Diff line change
@@ -211,12 +211,18 @@ export class Response {

const propsToMerge = pageResponse.mergeProps || []
const propsToDeepMerge = pageResponse.deepMergeProps || []
const mergeStrategies = pageResponse.mergeStrategies || []

propsToMerge.forEach((prop) => {
const incomingProp = pageResponse.props[prop]

if (Array.isArray(incomingProp)) {
pageResponse.props[prop] = [...((currentPage.get().props[prop] || []) as any[]), ...incomingProp]
pageResponse.props[prop] = mergeArrayWithStrategy(
(currentPage.get().props[prop] || []) as any[],
incomingProp,
prop,
mergeStrategies
)
} else if (typeof incomingProp === 'object' && incomingProp !== null) {
pageResponse.props[prop] = {
...((currentPage.get().props[prop] || []) as Record<string, any>),
@@ -230,17 +236,16 @@ export class Response {
const currentProp = currentPage.get().props[prop]

// Deep merge function to handle nested objects and arrays
const deepMerge = (target: any, source: any) => {
const deepMerge = (target: any, source: any, currentKey: string) => {
if (Array.isArray(source)) {
// Merge arrays by concatenating the existing and incoming elements
return [...(Array.isArray(target) ? target : []), ...source]
return mergeArrayWithStrategy(target, source, currentKey, mergeStrategies)
}

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])
acc[key] = deepMerge(target ? target[key] : undefined, source[key], `${currentKey}.${key}`)
return acc
},
{ ...target },
@@ -252,7 +257,7 @@ export class Response {
}

// Assign the deeply merged result back to props.
pageResponse.props[prop] = deepMerge(currentProp, incomingProp)
pageResponse.props[prop] = deepMerge(currentProp, incomingProp, prop)
})

pageResponse.props = { ...currentPage.get().props, ...pageResponse.props }
@@ -278,3 +283,38 @@ export class Response {
return errors[this.requestParams.all().errorBag || ''] || {}
}
}

function mergeArrayWithStrategy(target: any[], source: any[], currentKey: string, mergeStrategies: string[]) {
// Find the mergeStrategy that matches the currentKey
// For example: posts.data.id matches posts.data
const mergeStrategy = mergeStrategies.find((strategy) => {
const path = strategy.split('.').slice(0, -1).join('.')
return path === currentKey
})

if (mergeStrategy) {
const uniqueProperty = mergeStrategy.split('.').pop() || ''
const targetArray = Array.isArray(target) ? target : []
const map = new Map<any, any>()

targetArray.forEach(item => {
if (item && typeof item === 'object' && uniqueProperty in item) {
map.set(item[uniqueProperty], item)
} else {
map.set(Symbol(), item)
}
})

source.forEach(item => {
if (item && typeof item === 'object' && uniqueProperty in item) {
map.set(item[uniqueProperty], item)
} else {
map.set(Symbol(), item)
}
})

return Array.from(map.values())
}
// No mergeStrategy: default to concatenation
return [...(Array.isArray(target) ? target : []), ...source]
}
1 change: 1 addition & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
@@ -63,6 +63,7 @@ export interface Page<SharedProps extends PageProps = PageProps> {
deferredProps?: Record<string, VisitOptions['only']>
mergeProps?: string[]
deepMergeProps?: string[]
mergeStrategies?: string[]

/** @internal */
rememberedState: Record<string, unknown>
46 changes: 46 additions & 0 deletions packages/react/test-app/Pages/MergeStrategies.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { router } from '@inertiajs/react'
import { useState } from 'react'

export default ({ bar, foo, baz }) => {
const [page, setPage] = useState(foo.page)

const reloadIt = () => {
router.reload({
data: {
page,
},
only: ['foo', 'baz'],
onSuccess(visit) {
setPage(visit.props.foo.page)
},
})
}

const getFresh = () => {
setPage(0)
router.reload({
reset: ['foo', 'baz'],
})
}

return (
<>
<div>bar count is {bar.length}</div>
<div>baz count is {baz.length}</div>
<div>foo.data count is {foo.data.length}</div>
<div>first foo.data name is {foo.data[0].name}</div>
<div>last foo.data name is {foo.data[foo.data.length - 1].name}</div>
<div>foo.companies count is {foo.companies.length}</div>
<div>first foo.companies name is {foo.companies[0].name}</div>
<div>last foo.companies name is {foo.companies[foo.companies.length - 1].name}</div>
<div>foo.teams count is {foo.teams.length}</div>
<div>first foo.teams name is {foo.teams[0].name}</div>
<div>last foo.teams name is {foo.teams[foo.teams.length - 1].name}</div>
<div>foo.page is {foo.page}</div>
<div>foo.per_page is {foo.per_page}</div>
<div>foo.meta.label is {foo.meta.label}</div>
<button onClick={reloadIt}>Reload</button>
<button onClick={getFresh}>Get Fresh</button>
</>
)
}
45 changes: 45 additions & 0 deletions packages/svelte/test-app/Pages/MergeStrategies.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<script>
import { router } from '@inertiajs/svelte'

export let foo
export let bar
export let baz

let page = foo.page

const reloadIt = () => {
router.reload({
data: {
page,
},
only: ['foo', 'baz'],
onSuccess(visit) {
page = visit.props.foo.page
},
})
}

const getFresh = () => {
page = 0;
router.reload({
reset: ['foo', 'baz'],
})
}
</script>

<div>bar count is {bar.length}</div>
<div>baz count is {baz.length}</div>
<div>foo.data count is {foo.data.length}</div>
<div>first foo.data name is {foo.data[0].name}</div>
<div>last foo.data name is {foo.data[foo.data.length - 1].name}</div>
<div>foo.companies count is {foo.companies.length}</div>
<div>first foo.companies name is {foo.companies[0].name}</div>
<div>last foo.companies name is {foo.companies[foo.companies.length - 1].name}</div>
<div>foo.teams count is {foo.teams.length}</div>
<div>first foo.teams name is {foo.teams[0].name}</div>
<div>last foo.teams name is {foo.teams[foo.teams.length - 1].name}</div>
<div>foo.page is {foo.page}</div>
<div>foo.per_page is {foo.per_page}</div>
<div>foo.meta.label is {foo.meta.label}</div>
<button on:click={reloadIt}>Reload</button>
<button on:click={getFresh}>Get Fresh</button>
1 change: 1 addition & 0 deletions packages/vue3/src/app.ts
Original file line number Diff line number Diff line change
@@ -136,6 +136,7 @@ export function usePage<SharedProps extends PageProps>(): Page<SharedProps> {
deferredProps: computed(() => page.value?.deferredProps),
mergeProps: computed(() => page.value?.mergeProps),
deepMergeProps: computed(() => page.value?.deepMergeProps),
mergeStrategies: computed(() => page.value?.mergeStrategies),
rememberedState: computed(() => page.value?.rememberedState),
encryptHistory: computed(() => page.value?.encryptHistory),
})
55 changes: 55 additions & 0 deletions packages/vue3/test-app/Pages/MergeStrategies.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3'
import { ref } from 'vue'

const props = defineProps<{
foo: {
data: { id: number; name: string }[]
page: number
per_page: number
meta: { label: string }
}
bar: number[]
baz: number[]
}>()

const page = ref(props.foo.page)

const reloadIt = () => {
router.reload({
data: {
page: page.value,
},
only: ['foo', 'baz'],
onSuccess(visit) {
page.value = visit.props.foo.page
},
})
}

const getFresh = () => {
page.value = 0
router.reload({
reset: ['foo', 'baz'],
})
}
</script>

<template>
<div>bar count is {{ bar.length }}</div>
<div>baz count is {{ baz.length }}</div>
<div>foo.data count is {{ foo.data.length }}</div>
<div>first foo.data name is {{ foo.data[0].name }}</div>
<div>last foo.data name is {{ foo.data[foo.data.length - 1].name }}</div>
<div>foo.companies count is {{ foo.companies.length }}</div>
<div>first foo.companies name is {{ foo.companies[0].name }}</div>
<div>last foo.companies name is {{ foo.companies[foo.companies.length - 1].name }}</div>
<div>foo.teams count is {{ foo.teams.length }}</div>
<div>first foo.teams name is {{ foo.teams[0].name }}</div>
<div>last foo.teams name is {{ foo.teams[foo.teams.length - 1].name }}</div>
<div>foo.page is {{ foo.page }}</div>
<div>foo.per_page is {{ foo.per_page }}</div>
<div>foo.meta.label is {{ foo.meta.label }}</div>
<button @click="reloadIt">Reload</button>
<button @click="getFresh">Get Fresh</button>
</template>
46 changes: 46 additions & 0 deletions tests/app/server.js
Original file line number Diff line number Diff line change
@@ -328,6 +328,52 @@ app.get('/deep-merge-props', (req, res) => {
})
})

app.get('/merge-strategies', (req, res) => {
const labels = ['first', 'second', 'third', 'fourth', 'fifth']

const perPage = 5
const page = parseInt(req.query.page ?? -1, 10) + 1

const users = new Array(perPage).fill(1).map((_, index) => ({
id: index + 1,
name: `User ${index + 1}`,
}))

const companies = new Array(perPage).fill(1).map((_, index) => ({
otherId: index + 1,
name: `Company ${index + 1}`,
}))

const teams = new Array(perPage).fill(1).map((_, index) => ({
uuid: (Math.random() + 1).toString(36).substring(7),
name: `Team ${perPage * page + index + 1}`,
}))

inertia.render(req, res, {
component: 'MergeStrategies',
props: {
bar: new Array(perPage).fill(1),
baz: new Array(perPage).fill(1),
foo: {
data: users,
companies,
teams,
page,
per_page: 5,
meta: {
label: labels[page],
},
},
},
...(req.headers['x-inertia-reset']
? {}
: {
deepMergeProps: ['foo', 'baz'],
mergeStrategies: ['foo.data.id', 'foo.companies.otherId', 'foo.teams.uuid'],
}),
})
})

app.get('/deferred-props/page-1', (req, res) => {
if (!req.headers['x-inertia-partial-data']) {
return inertia.render(req, res, {
88 changes: 88 additions & 0 deletions tests/merge-strategies.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { expect, test } from '@playwright/test'

test('can merge props with custom strategies', async ({ page }) => {
await page.goto('/merge-strategies')

await expect(page.getByText('bar count is 5')).toBeVisible()
await expect(page.getByText('baz count is 5')).toBeVisible()
await expect(page.getByText('foo.data count is 5')).toBeVisible()
await expect(page.getByText('first foo.data name is User 1')).toBeVisible()
await expect(page.getByText('last foo.data name is User 5')).toBeVisible()
await expect(page.getByText('foo.companies count is 5')).toBeVisible()
await expect(page.getByText('first foo.companies name is Company 1')).toBeVisible()
await expect(page.getByText('last foo.companies name is Company 5')).toBeVisible()
await expect(page.getByText('foo.teams count is 5')).toBeVisible()
await expect(page.getByText('first foo.teams name is Team 1')).toBeVisible()
await expect(page.getByText('last foo.teams name is Team 5')).toBeVisible()
await expect(page.getByText('foo.page is 0')).toBeVisible()
await expect(page.getByText('foo.per_page is 5')).toBeVisible()
await expect(page.getByText('foo.meta.label is first')).toBeVisible()

await page.getByRole('button', { name: 'Reload' }).click()

await expect(page.getByText('bar count is 5')).toBeVisible()
await expect(page.getByText('baz count is 10')).toBeVisible()
await expect(page.getByText('foo.data count is 5')).toBeVisible()
await expect(page.getByText('first foo.data name is User 1')).toBeVisible()
await expect(page.getByText('last foo.data name is User 5')).toBeVisible()
await expect(page.getByText('foo.companies count is 5')).toBeVisible()
await expect(page.getByText('first foo.companies name is Company 1')).toBeVisible()
await expect(page.getByText('last foo.companies name is Company 5')).toBeVisible()
await expect(page.getByText('foo.teams count is 10')).toBeVisible()
await expect(page.getByText('first foo.teams name is Team 1')).toBeVisible()
await expect(page.getByText('last foo.teams name is Team 10')).toBeVisible()
await expect(page.getByText('foo.page is 1')).toBeVisible()
await expect(page.getByText('foo.per_page is 5')).toBeVisible()
await expect(page.getByText('foo.meta.label is second')).toBeVisible()

await page.getByRole('button', { name: 'Reload' }).click()

await expect(page.getByText('bar count is 5')).toBeVisible()
await expect(page.getByText('baz count is 15')).toBeVisible()
await expect(page.getByText('foo.data count is 5')).toBeVisible()
await expect(page.getByText('first foo.data name is User 1')).toBeVisible()
await expect(page.getByText('last foo.data name is User 5')).toBeVisible()
await expect(page.getByText('foo.companies count is 5')).toBeVisible()
await expect(page.getByText('first foo.companies name is Company 1')).toBeVisible()
await expect(page.getByText('last foo.companies name is Company 5')).toBeVisible()
await expect(page.getByText('foo.teams count is 15')).toBeVisible()
await expect(page.getByText('first foo.teams name is Team 1')).toBeVisible()
await expect(page.getByText('last foo.teams name is Team 15')).toBeVisible()
await expect(page.getByText('foo.page is 2')).toBeVisible()
await expect(page.getByText('foo.per_page is 5')).toBeVisible()
await expect(page.getByText('foo.meta.label is third')).toBeVisible()

await page.getByRole('button', { name: 'Get Fresh' }).click()

await expect(page.getByText('bar count is 5')).toBeVisible()
await expect(page.getByText('baz count is 5')).toBeVisible()
await expect(page.getByText('foo.data count is 5')).toBeVisible()
await expect(page.getByText('first foo.data name is User 1')).toBeVisible()
await expect(page.getByText('last foo.data name is User 5')).toBeVisible()
await expect(page.getByText('foo.companies count is 5')).toBeVisible()
await expect(page.getByText('first foo.companies name is Company 1')).toBeVisible()
await expect(page.getByText('last foo.companies name is Company 5')).toBeVisible()
await expect(page.getByText('foo.teams count is 5')).toBeVisible()
await expect(page.getByText('first foo.teams name is Team 1')).toBeVisible()
await expect(page.getByText('last foo.teams name is Team 5')).toBeVisible()
await expect(page.getByText('foo.page is 0')).toBeVisible()
await expect(page.getByText('foo.per_page is 5')).toBeVisible()
await expect(page.getByText('foo.meta.label is first')).toBeVisible()

await page.getByRole('button', { name: 'Reload' }).click()

await expect(page.getByText('bar count is 5')).toBeVisible()
await expect(page.getByText('baz count is 10')).toBeVisible()
await expect(page.getByText('foo.data count is 5')).toBeVisible()
await expect(page.getByText('first foo.data name is User 1')).toBeVisible()
await expect(page.getByText('last foo.data name is User 5')).toBeVisible()
await expect(page.getByText('foo.companies count is 5')).toBeVisible()
await expect(page.getByText('first foo.companies name is Company 1')).toBeVisible()
await expect(page.getByText('last foo.companies name is Company 5')).toBeVisible()
await expect(page.getByText('foo.teams count is 10')).toBeVisible()
await expect(page.getByText('first foo.teams name is Team 1')).toBeVisible()
await expect(page.getByText('last foo.teams name is Team 10')).toBeVisible()
await expect(page.getByText('foo.page is 1')).toBeVisible()
await expect(page.getByText('foo.per_page is 5')).toBeVisible()
await expect(page.getByText('foo.meta.label is second')).toBeVisible()
})
Loading
Oops, something went wrong.