Skip to content

Commit 31c01ef

Browse files
authored
Merge pull request #2344 from mpociot/feature/merge-strategies
Allow deepMerge on custom properties
2 parents 491efb1 + bdc5ef6 commit 31c01ef

File tree

8 files changed

+328
-6
lines changed

8 files changed

+328
-6
lines changed

packages/core/src/response.ts

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -211,12 +211,18 @@ export class Response {
211211

212212
const propsToMerge = pageResponse.mergeProps || []
213213
const propsToDeepMerge = pageResponse.deepMergeProps || []
214+
const mergeStrategies = pageResponse.mergeStrategies || []
214215

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

218219
if (Array.isArray(incomingProp)) {
219-
pageResponse.props[prop] = [...((currentPage.get().props[prop] || []) as any[]), ...incomingProp]
220+
pageResponse.props[prop] = mergeArrayWithStrategy(
221+
(currentPage.get().props[prop] || []) as any[],
222+
incomingProp,
223+
prop,
224+
mergeStrategies
225+
)
220226
} else if (typeof incomingProp === 'object' && incomingProp !== null) {
221227
pageResponse.props[prop] = {
222228
...((currentPage.get().props[prop] || []) as Record<string, any>),
@@ -230,17 +236,16 @@ export class Response {
230236
const currentProp = currentPage.get().props[prop]
231237

232238
// Deep merge function to handle nested objects and arrays
233-
const deepMerge = (target: any, source: any) => {
239+
const deepMerge = (target: any, source: any, currentKey: string) => {
234240
if (Array.isArray(source)) {
235-
// Merge arrays by concatenating the existing and incoming elements
236-
return [...(Array.isArray(target) ? target : []), ...source]
241+
return mergeArrayWithStrategy(target, source, currentKey, mergeStrategies)
237242
}
238243

239244
if (typeof source === 'object' && source !== null) {
240245
// Merge objects by iterating over keys
241246
return Object.keys(source).reduce(
242247
(acc, key) => {
243-
acc[key] = deepMerge(target ? target[key] : undefined, source[key])
248+
acc[key] = deepMerge(target ? target[key] : undefined, source[key], `${currentKey}.${key}`)
244249
return acc
245250
},
246251
{ ...target },
@@ -252,7 +257,7 @@ export class Response {
252257
}
253258

254259
// Assign the deeply merged result back to props.
255-
pageResponse.props[prop] = deepMerge(currentProp, incomingProp)
260+
pageResponse.props[prop] = deepMerge(currentProp, incomingProp, prop)
256261
})
257262

258263
pageResponse.props = { ...currentPage.get().props, ...pageResponse.props }
@@ -278,3 +283,38 @@ export class Response {
278283
return errors[this.requestParams.all().errorBag || ''] || {}
279284
}
280285
}
286+
287+
function mergeArrayWithStrategy(target: any[], source: any[], currentKey: string, mergeStrategies: string[]) {
288+
// Find the mergeStrategy that matches the currentKey
289+
// For example: posts.data.id matches posts.data
290+
const mergeStrategy = mergeStrategies.find((strategy) => {
291+
const path = strategy.split('.').slice(0, -1).join('.')
292+
return path === currentKey
293+
})
294+
295+
if (mergeStrategy) {
296+
const uniqueProperty = mergeStrategy.split('.').pop() || ''
297+
const targetArray = Array.isArray(target) ? target : []
298+
const map = new Map<any, any>()
299+
300+
targetArray.forEach(item => {
301+
if (item && typeof item === 'object' && uniqueProperty in item) {
302+
map.set(item[uniqueProperty], item)
303+
} else {
304+
map.set(Symbol(), item)
305+
}
306+
})
307+
308+
source.forEach(item => {
309+
if (item && typeof item === 'object' && uniqueProperty in item) {
310+
map.set(item[uniqueProperty], item)
311+
} else {
312+
map.set(Symbol(), item)
313+
}
314+
})
315+
316+
return Array.from(map.values())
317+
}
318+
// No mergeStrategy: default to concatenation
319+
return [...(Array.isArray(target) ? target : []), ...source]
320+
}

packages/core/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export interface Page<SharedProps extends PageProps = PageProps> {
6363
deferredProps?: Record<string, VisitOptions['only']>
6464
mergeProps?: string[]
6565
deepMergeProps?: string[]
66+
mergeStrategies?: string[]
6667

6768
/** @internal */
6869
rememberedState: Record<string, unknown>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { router } from '@inertiajs/react'
2+
import { useState } from 'react'
3+
4+
export default ({ bar, foo, baz }) => {
5+
const [page, setPage] = useState(foo.page)
6+
7+
const reloadIt = () => {
8+
router.reload({
9+
data: {
10+
page,
11+
},
12+
only: ['foo', 'baz'],
13+
onSuccess(visit) {
14+
setPage(visit.props.foo.page)
15+
},
16+
})
17+
}
18+
19+
const getFresh = () => {
20+
setPage(0)
21+
router.reload({
22+
reset: ['foo', 'baz'],
23+
})
24+
}
25+
26+
return (
27+
<>
28+
<div>bar count is {bar.length}</div>
29+
<div>baz count is {baz.length}</div>
30+
<div>foo.data count is {foo.data.length}</div>
31+
<div>first foo.data name is {foo.data[0].name}</div>
32+
<div>last foo.data name is {foo.data[foo.data.length - 1].name}</div>
33+
<div>foo.companies count is {foo.companies.length}</div>
34+
<div>first foo.companies name is {foo.companies[0].name}</div>
35+
<div>last foo.companies name is {foo.companies[foo.companies.length - 1].name}</div>
36+
<div>foo.teams count is {foo.teams.length}</div>
37+
<div>first foo.teams name is {foo.teams[0].name}</div>
38+
<div>last foo.teams name is {foo.teams[foo.teams.length - 1].name}</div>
39+
<div>foo.page is {foo.page}</div>
40+
<div>foo.per_page is {foo.per_page}</div>
41+
<div>foo.meta.label is {foo.meta.label}</div>
42+
<button onClick={reloadIt}>Reload</button>
43+
<button onClick={getFresh}>Get Fresh</button>
44+
</>
45+
)
46+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<script>
2+
import { router } from '@inertiajs/svelte'
3+
4+
export let foo
5+
export let bar
6+
export let baz
7+
8+
let page = foo.page
9+
10+
const reloadIt = () => {
11+
router.reload({
12+
data: {
13+
page,
14+
},
15+
only: ['foo', 'baz'],
16+
onSuccess(visit) {
17+
page = visit.props.foo.page
18+
},
19+
})
20+
}
21+
22+
const getFresh = () => {
23+
page = 0;
24+
router.reload({
25+
reset: ['foo', 'baz'],
26+
})
27+
}
28+
</script>
29+
30+
<div>bar count is {bar.length}</div>
31+
<div>baz count is {baz.length}</div>
32+
<div>foo.data count is {foo.data.length}</div>
33+
<div>first foo.data name is {foo.data[0].name}</div>
34+
<div>last foo.data name is {foo.data[foo.data.length - 1].name}</div>
35+
<div>foo.companies count is {foo.companies.length}</div>
36+
<div>first foo.companies name is {foo.companies[0].name}</div>
37+
<div>last foo.companies name is {foo.companies[foo.companies.length - 1].name}</div>
38+
<div>foo.teams count is {foo.teams.length}</div>
39+
<div>first foo.teams name is {foo.teams[0].name}</div>
40+
<div>last foo.teams name is {foo.teams[foo.teams.length - 1].name}</div>
41+
<div>foo.page is {foo.page}</div>
42+
<div>foo.per_page is {foo.per_page}</div>
43+
<div>foo.meta.label is {foo.meta.label}</div>
44+
<button on:click={reloadIt}>Reload</button>
45+
<button on:click={getFresh}>Get Fresh</button>

packages/vue3/src/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export function usePage<SharedProps extends PageProps>(): Page<SharedProps> {
136136
deferredProps: computed(() => page.value?.deferredProps),
137137
mergeProps: computed(() => page.value?.mergeProps),
138138
deepMergeProps: computed(() => page.value?.deepMergeProps),
139+
mergeStrategies: computed(() => page.value?.mergeStrategies),
139140
rememberedState: computed(() => page.value?.rememberedState),
140141
encryptHistory: computed(() => page.value?.encryptHistory),
141142
})
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<script setup lang="ts">
2+
import { router } from '@inertiajs/vue3'
3+
import { ref } from 'vue'
4+
5+
const props = defineProps<{
6+
foo: {
7+
data: { id: number; name: string }[]
8+
page: number
9+
per_page: number
10+
meta: { label: string }
11+
}
12+
bar: number[]
13+
baz: number[]
14+
}>()
15+
16+
const page = ref(props.foo.page)
17+
18+
const reloadIt = () => {
19+
router.reload({
20+
data: {
21+
page: page.value,
22+
},
23+
only: ['foo', 'baz'],
24+
onSuccess(visit) {
25+
page.value = visit.props.foo.page
26+
},
27+
})
28+
}
29+
30+
const getFresh = () => {
31+
page.value = 0
32+
router.reload({
33+
reset: ['foo', 'baz'],
34+
})
35+
}
36+
</script>
37+
38+
<template>
39+
<div>bar count is {{ bar.length }}</div>
40+
<div>baz count is {{ baz.length }}</div>
41+
<div>foo.data count is {{ foo.data.length }}</div>
42+
<div>first foo.data name is {{ foo.data[0].name }}</div>
43+
<div>last foo.data name is {{ foo.data[foo.data.length - 1].name }}</div>
44+
<div>foo.companies count is {{ foo.companies.length }}</div>
45+
<div>first foo.companies name is {{ foo.companies[0].name }}</div>
46+
<div>last foo.companies name is {{ foo.companies[foo.companies.length - 1].name }}</div>
47+
<div>foo.teams count is {{ foo.teams.length }}</div>
48+
<div>first foo.teams name is {{ foo.teams[0].name }}</div>
49+
<div>last foo.teams name is {{ foo.teams[foo.teams.length - 1].name }}</div>
50+
<div>foo.page is {{ foo.page }}</div>
51+
<div>foo.per_page is {{ foo.per_page }}</div>
52+
<div>foo.meta.label is {{ foo.meta.label }}</div>
53+
<button @click="reloadIt">Reload</button>
54+
<button @click="getFresh">Get Fresh</button>
55+
</template>

tests/app/server.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,52 @@ app.get('/deep-merge-props', (req, res) => {
328328
})
329329
})
330330

331+
app.get('/merge-strategies', (req, res) => {
332+
const labels = ['first', 'second', 'third', 'fourth', 'fifth']
333+
334+
const perPage = 5
335+
const page = parseInt(req.query.page ?? -1, 10) + 1
336+
337+
const users = new Array(perPage).fill(1).map((_, index) => ({
338+
id: index + 1,
339+
name: `User ${index + 1}`,
340+
}))
341+
342+
const companies = new Array(perPage).fill(1).map((_, index) => ({
343+
otherId: index + 1,
344+
name: `Company ${index + 1}`,
345+
}))
346+
347+
const teams = new Array(perPage).fill(1).map((_, index) => ({
348+
uuid: (Math.random() + 1).toString(36).substring(7),
349+
name: `Team ${perPage * page + index + 1}`,
350+
}))
351+
352+
inertia.render(req, res, {
353+
component: 'MergeStrategies',
354+
props: {
355+
bar: new Array(perPage).fill(1),
356+
baz: new Array(perPage).fill(1),
357+
foo: {
358+
data: users,
359+
companies,
360+
teams,
361+
page,
362+
per_page: 5,
363+
meta: {
364+
label: labels[page],
365+
},
366+
},
367+
},
368+
...(req.headers['x-inertia-reset']
369+
? {}
370+
: {
371+
deepMergeProps: ['foo', 'baz'],
372+
mergeStrategies: ['foo.data.id', 'foo.companies.otherId', 'foo.teams.uuid'],
373+
}),
374+
})
375+
})
376+
331377
app.get('/deferred-props/page-1', (req, res) => {
332378
if (!req.headers['x-inertia-partial-data']) {
333379
return inertia.render(req, res, {

tests/merge-strategies.spec.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { expect, test } from '@playwright/test'
2+
3+
test('can merge props with custom strategies', async ({ page }) => {
4+
await page.goto('/merge-strategies')
5+
6+
await expect(page.getByText('bar count is 5')).toBeVisible()
7+
await expect(page.getByText('baz count is 5')).toBeVisible()
8+
await expect(page.getByText('foo.data count is 5')).toBeVisible()
9+
await expect(page.getByText('first foo.data name is User 1')).toBeVisible()
10+
await expect(page.getByText('last foo.data name is User 5')).toBeVisible()
11+
await expect(page.getByText('foo.companies count is 5')).toBeVisible()
12+
await expect(page.getByText('first foo.companies name is Company 1')).toBeVisible()
13+
await expect(page.getByText('last foo.companies name is Company 5')).toBeVisible()
14+
await expect(page.getByText('foo.teams count is 5')).toBeVisible()
15+
await expect(page.getByText('first foo.teams name is Team 1')).toBeVisible()
16+
await expect(page.getByText('last foo.teams name is Team 5')).toBeVisible()
17+
await expect(page.getByText('foo.page is 0')).toBeVisible()
18+
await expect(page.getByText('foo.per_page is 5')).toBeVisible()
19+
await expect(page.getByText('foo.meta.label is first')).toBeVisible()
20+
21+
await page.getByRole('button', { name: 'Reload' }).click()
22+
23+
await expect(page.getByText('bar count is 5')).toBeVisible()
24+
await expect(page.getByText('baz count is 10')).toBeVisible()
25+
await expect(page.getByText('foo.data count is 5')).toBeVisible()
26+
await expect(page.getByText('first foo.data name is User 1')).toBeVisible()
27+
await expect(page.getByText('last foo.data name is User 5')).toBeVisible()
28+
await expect(page.getByText('foo.companies count is 5')).toBeVisible()
29+
await expect(page.getByText('first foo.companies name is Company 1')).toBeVisible()
30+
await expect(page.getByText('last foo.companies name is Company 5')).toBeVisible()
31+
await expect(page.getByText('foo.teams count is 10')).toBeVisible()
32+
await expect(page.getByText('first foo.teams name is Team 1')).toBeVisible()
33+
await expect(page.getByText('last foo.teams name is Team 10')).toBeVisible()
34+
await expect(page.getByText('foo.page is 1')).toBeVisible()
35+
await expect(page.getByText('foo.per_page is 5')).toBeVisible()
36+
await expect(page.getByText('foo.meta.label is second')).toBeVisible()
37+
38+
await page.getByRole('button', { name: 'Reload' }).click()
39+
40+
await expect(page.getByText('bar count is 5')).toBeVisible()
41+
await expect(page.getByText('baz count is 15')).toBeVisible()
42+
await expect(page.getByText('foo.data count is 5')).toBeVisible()
43+
await expect(page.getByText('first foo.data name is User 1')).toBeVisible()
44+
await expect(page.getByText('last foo.data name is User 5')).toBeVisible()
45+
await expect(page.getByText('foo.companies count is 5')).toBeVisible()
46+
await expect(page.getByText('first foo.companies name is Company 1')).toBeVisible()
47+
await expect(page.getByText('last foo.companies name is Company 5')).toBeVisible()
48+
await expect(page.getByText('foo.teams count is 15')).toBeVisible()
49+
await expect(page.getByText('first foo.teams name is Team 1')).toBeVisible()
50+
await expect(page.getByText('last foo.teams name is Team 15')).toBeVisible()
51+
await expect(page.getByText('foo.page is 2')).toBeVisible()
52+
await expect(page.getByText('foo.per_page is 5')).toBeVisible()
53+
await expect(page.getByText('foo.meta.label is third')).toBeVisible()
54+
55+
await page.getByRole('button', { name: 'Get Fresh' }).click()
56+
57+
await expect(page.getByText('bar count is 5')).toBeVisible()
58+
await expect(page.getByText('baz count is 5')).toBeVisible()
59+
await expect(page.getByText('foo.data count is 5')).toBeVisible()
60+
await expect(page.getByText('first foo.data name is User 1')).toBeVisible()
61+
await expect(page.getByText('last foo.data name is User 5')).toBeVisible()
62+
await expect(page.getByText('foo.companies count is 5')).toBeVisible()
63+
await expect(page.getByText('first foo.companies name is Company 1')).toBeVisible()
64+
await expect(page.getByText('last foo.companies name is Company 5')).toBeVisible()
65+
await expect(page.getByText('foo.teams count is 5')).toBeVisible()
66+
await expect(page.getByText('first foo.teams name is Team 1')).toBeVisible()
67+
await expect(page.getByText('last foo.teams name is Team 5')).toBeVisible()
68+
await expect(page.getByText('foo.page is 0')).toBeVisible()
69+
await expect(page.getByText('foo.per_page is 5')).toBeVisible()
70+
await expect(page.getByText('foo.meta.label is first')).toBeVisible()
71+
72+
await page.getByRole('button', { name: 'Reload' }).click()
73+
74+
await expect(page.getByText('bar count is 5')).toBeVisible()
75+
await expect(page.getByText('baz count is 10')).toBeVisible()
76+
await expect(page.getByText('foo.data count is 5')).toBeVisible()
77+
await expect(page.getByText('first foo.data name is User 1')).toBeVisible()
78+
await expect(page.getByText('last foo.data name is User 5')).toBeVisible()
79+
await expect(page.getByText('foo.companies count is 5')).toBeVisible()
80+
await expect(page.getByText('first foo.companies name is Company 1')).toBeVisible()
81+
await expect(page.getByText('last foo.companies name is Company 5')).toBeVisible()
82+
await expect(page.getByText('foo.teams count is 10')).toBeVisible()
83+
await expect(page.getByText('first foo.teams name is Team 1')).toBeVisible()
84+
await expect(page.getByText('last foo.teams name is Team 10')).toBeVisible()
85+
await expect(page.getByText('foo.page is 1')).toBeVisible()
86+
await expect(page.getByText('foo.per_page is 5')).toBeVisible()
87+
await expect(page.getByText('foo.meta.label is second')).toBeVisible()
88+
})

0 commit comments

Comments
 (0)