Skip to content

Commit f8836f5

Browse files
authored
Support for merging nested prop paths (#2561)
* wip * tests * Fix code style * Update types.ts * Improve prepend with matchOn * Cleanup, move `scrollProps` to `<InfiniteScroll>` branch * Complex data structue test * Fix code style * Increase test object complexity * Update ComplexMergeSelective.svelte
1 parent 7fe06fe commit f8836f5

File tree

11 files changed

+429
-41
lines changed

11 files changed

+429
-41
lines changed

packages/core/src/response.ts

Lines changed: 101 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AxiosResponse } from 'axios'
2+
import { get, set } from 'lodash-es'
23
import { router } from '.'
34
import { fireErrorEvent, fireInvalidEvent, firePrefetchedEvent, fireSuccessEvent } from './events'
45
import { history } from './history'
@@ -221,50 +222,60 @@ export class Response {
221222
return
222223
}
223224

224-
const propsToMerge = pageResponse.mergeProps || []
225+
const propsToAppend = pageResponse.mergeProps || []
226+
const propsToPrepend = pageResponse.prependProps || []
225227
const propsToDeepMerge = pageResponse.deepMergeProps || []
226228
const matchPropsOn = pageResponse.matchPropsOn || []
227229

228-
propsToMerge.forEach((prop) => {
229-
const incomingProp = pageResponse.props[prop]
230+
const mergeProp = (prop: string, shouldAppend: boolean) => {
231+
const currentProp = get(currentPage.get().props, prop)
232+
const incomingProp = get(pageResponse.props, prop)
230233

231234
if (Array.isArray(incomingProp)) {
232-
pageResponse.props[prop] = this.mergeOrMatchItems(
233-
(currentPage.get().props[prop] || []) as any[],
235+
const newArray = this.mergeOrMatchItems(
236+
(currentProp || []) as any[],
234237
incomingProp,
235238
prop,
236239
matchPropsOn,
240+
shouldAppend,
237241
)
242+
243+
set(pageResponse.props, prop, newArray)
238244
} else if (typeof incomingProp === 'object' && incomingProp !== null) {
239-
pageResponse.props[prop] = {
240-
...((currentPage.get().props[prop] || []) as Record<string, any>),
245+
const newObject = {
246+
...(currentProp || {}),
241247
...incomingProp,
242248
}
249+
250+
set(pageResponse.props, prop, newObject)
243251
}
244-
})
252+
}
253+
254+
propsToAppend.forEach((prop) => mergeProp(prop, true))
255+
propsToPrepend.forEach((prop) => mergeProp(prop, false))
245256

246257
propsToDeepMerge.forEach((prop) => {
247-
const incomingProp = pageResponse.props[prop]
248258
const currentProp = currentPage.get().props[prop]
259+
const incomingProp = pageResponse.props[prop]
249260

250261
// Function to recursively merge objects and arrays
251-
const deepMerge = (target: any, source: any, currentKey: string) => {
262+
const deepMerge = (target: any, source: any, matchProp: string) => {
252263
if (Array.isArray(source)) {
253-
return this.mergeOrMatchItems(target, source, currentKey, matchPropsOn)
264+
return this.mergeOrMatchItems(target, source, matchProp, matchPropsOn)
254265
}
255266

256267
if (typeof source === 'object' && source !== null) {
257268
// Merge objects by iterating over keys
258269
return Object.keys(source).reduce(
259270
(acc, key) => {
260-
acc[key] = deepMerge(target ? target[key] : undefined, source[key], `${currentKey}.${key}`)
271+
acc[key] = deepMerge(target ? target[key] : undefined, source[key], `${matchProp}.${key}`)
261272
return acc
262273
},
263274
{ ...target },
264275
)
265276
}
266277

267-
// f the source is neither an array nor an object, simply return the it
278+
// If the source is neither an array nor an object, simply return the it
268279
return source
269280
}
270281

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

278-
protected mergeOrMatchItems(target: any[], source: any[], currentKey: string, matchPropsOn: string[]) {
279-
// Determine if there's a specific key to match items.
280-
// E.g.: matchPropsOn = ['posts.data.id'] and currentKey = 'posts.data' will match.
281-
const matchOn = matchPropsOn.find((key) => {
282-
const path = key.split('.').slice(0, -1).join('.')
283-
return path === currentKey
289+
protected mergeOrMatchItems(
290+
existingItems: any[],
291+
newItems: any[],
292+
matchProp: string,
293+
matchPropsOn: string[],
294+
shouldAppend = true,
295+
) {
296+
const items = Array.isArray(existingItems) ? existingItems : []
297+
298+
// Find the matching key for this specific property path
299+
const matchingKey = matchPropsOn.find((key) => {
300+
const keyPath = key.split('.').slice(0, -1).join('.')
301+
302+
return keyPath === matchProp
284303
})
285304

286-
if (!matchOn) {
287-
// No key found to match on, just concatenate the arrays
288-
return [...(Array.isArray(target) ? target : []), ...source]
305+
// If no matching key is configured, simply concatenate the arrays
306+
if (!matchingKey) {
307+
return shouldAppend ? [...items, ...newItems] : [...newItems, ...items]
289308
}
290309

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

296-
// Populate the map with items from the target array, using the unique property as the key.
297-
// If an item doesn't have the unique property or isn't an object, a unique Symbol is used as the key.
298-
targetArray.forEach((item) => {
299-
if (item && typeof item === 'object' && uniqueProperty in item) {
300-
map.set(item[uniqueProperty], item)
301-
} else {
302-
map.set(Symbol(), item)
313+
// Create a map of new items by their unique property lookups
314+
const newItemsMap = new Map()
315+
316+
newItems.forEach((item) => {
317+
if (this.hasUniqueProperty(item, uniqueProperty)) {
318+
newItemsMap.set(item[uniqueProperty], item)
303319
}
304320
})
305321

306-
// Iterate through the source array. If an item's unique property matches an existing key in the map,
307-
// update the item. Otherwise, add the new item to the map.
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)
322+
return shouldAppend
323+
? this.appendWithMatching(items, newItems, newItemsMap, uniqueProperty)
324+
: this.prependWithMatching(items, newItems, newItemsMap, uniqueProperty)
325+
}
326+
327+
protected appendWithMatching(
328+
existingItems: any[],
329+
newItems: any[],
330+
newItemsMap: Map<any, any>,
331+
uniqueProperty: string,
332+
): any[] {
333+
// Update existing items with new values, keep non-matching items
334+
const updatedExisting = existingItems.map((item) => {
335+
if (this.hasUniqueProperty(item, uniqueProperty) && newItemsMap.has(item[uniqueProperty])) {
336+
return newItemsMap.get(item[uniqueProperty])
313337
}
338+
339+
return item
340+
})
341+
342+
// Filter new items to only include those not already in existing items
343+
const newItemsToAdd = newItems.filter((item) => {
344+
if (!this.hasUniqueProperty(item, uniqueProperty)) {
345+
return true // Always add items without unique property
346+
}
347+
348+
return !existingItems.some(
349+
(existing) =>
350+
this.hasUniqueProperty(existing, uniqueProperty) && existing[uniqueProperty] === item[uniqueProperty],
351+
)
352+
})
353+
354+
return [...updatedExisting, ...newItemsToAdd]
355+
}
356+
357+
protected prependWithMatching(
358+
existingItems: any[],
359+
newItems: any[],
360+
newItemsMap: Map<any, any>,
361+
uniqueProperty: string,
362+
): any[] {
363+
// Filter existing items, keeping only those not being updated
364+
const untouchedExisting = existingItems.filter((item) => {
365+
if (this.hasUniqueProperty(item, uniqueProperty)) {
366+
return !newItemsMap.has(item[uniqueProperty])
367+
}
368+
369+
return true
314370
})
315371

316-
return Array.from(map.values())
372+
return [...newItems, ...untouchedExisting]
373+
}
374+
375+
protected hasUniqueProperty(item: any, property: string): boolean {
376+
return item && typeof item === 'object' && property in item
317377
}
318378

319379
protected async setRememberedState(pageResponse: Page): Promise<void> {

packages/core/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ export interface Page<SharedProps extends PageProps = PageProps> {
118118
encryptHistory: boolean
119119
deferredProps?: Record<string, VisitOptions['only']>
120120
mergeProps?: string[]
121+
prependProps?: string[]
121122
deepMergeProps?: string[]
122123
matchPropsOn?: string[]
123124

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { router } from '@inertiajs/react'
2+
3+
export default ({
4+
mixed,
5+
}: {
6+
mixed: {
7+
name: string
8+
users: string[]
9+
chat: { data: number[] }
10+
post: { id: number; comments: { allowed: boolean; data: string[] } }
11+
}
12+
}) => {
13+
const reload = () => {
14+
router.reload({
15+
only: ['mixed'],
16+
})
17+
}
18+
19+
return (
20+
<div>
21+
<div>name is {mixed.name}</div>
22+
<div>users: {mixed.users.join(', ')}</div>
23+
<div>chat.data: {mixed.chat.data.join(', ')}</div>
24+
<div>post.id: {mixed.post.id}</div>
25+
<div>post.comments.allowed: {mixed.post.comments.allowed ? 'true' : 'false'}</div>
26+
<div>post.comments.data: {mixed.post.comments.data.join(', ')}</div>
27+
<button onClick={reload}>Reload</button>
28+
</div>
29+
)
30+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { router } from '@inertiajs/react'
2+
3+
export default ({
4+
users,
5+
}: {
6+
users: { data: { id: number; name: string }[]; meta: { page: number; perPage: number } }
7+
}) => {
8+
const loadMore = () => {
9+
router.reload({
10+
only: ['users'],
11+
data: { page: users.meta.page + 1 },
12+
})
13+
}
14+
15+
return (
16+
<div>
17+
<p id="users">{users.data.map((user) => user.name).join(', ')}</p>
18+
<p id="meta">
19+
Page: {users.meta.page}, Per Page: {users.meta.perPage}
20+
</p>
21+
<button onClick={loadMore}>Load More</button>
22+
</div>
23+
)
24+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<script lang="ts">
2+
import { router } from '@inertiajs/svelte'
3+
4+
export let mixed: {
5+
name: string
6+
users: string[]
7+
chat: { data: number[] }
8+
post: { id: number; comments: { allowed: boolean; data: string[] } }
9+
}
10+
11+
const reload = () => {
12+
router.reload({
13+
only: ['mixed'],
14+
})
15+
}
16+
</script>
17+
18+
<div>name is {mixed.name}</div>
19+
<div>users: {mixed.users.join(', ')}</div>
20+
<div>chat.data: {mixed.chat.data.join(', ')}</div>
21+
<div>post.id: {mixed.post.id}</div>
22+
<div>post.comments.allowed: {mixed.post.comments.allowed ? 'true' : 'false'}</div>
23+
<div>post.comments.data: {mixed.post.comments.data.join(', ')}</div>
24+
<button on:click={reload}>Reload</button>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<script lang="ts">
2+
import { router } from '@inertiajs/svelte'
3+
4+
export let users: { data: { id: number; name: string }[]; meta: { page: number; perPage: number } } = {
5+
data: [],
6+
meta: { page: 1, perPage: 10 },
7+
}
8+
9+
const loadMore = () => {
10+
router.reload({
11+
only: ['users'],
12+
data: { page: users.meta.page + 1 },
13+
})
14+
}
15+
</script>
16+
17+
<div>
18+
<p id="users">{users.data.map((user) => user.name).join(', ')}</p>
19+
<p id="meta">Page: {users.meta.page}, Per Page: {users.meta.perPage}</p>
20+
<button on:click={loadMore}>Load More</button>
21+
</div>

packages/vue3/src/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export function usePage<SharedProps extends PageProps>(): Page<SharedProps> {
135135
clearHistory: computed(() => page.value?.clearHistory),
136136
deferredProps: computed(() => page.value?.deferredProps),
137137
mergeProps: computed(() => page.value?.mergeProps),
138+
prependProps: computed(() => page.value?.prependProps),
138139
deepMergeProps: computed(() => page.value?.deepMergeProps),
139140
matchPropsOn: computed(() => page.value?.matchPropsOn),
140141
rememberedState: computed(() => page.value?.rememberedState),
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<script setup lang="ts">
2+
import { router } from '@inertiajs/vue3'
3+
4+
defineProps<{
5+
mixed: {
6+
name: string
7+
users: string[]
8+
chat: { data: number[] }
9+
post: { id: number; comments: { allowed: boolean; data: string[] } }
10+
}
11+
}>()
12+
13+
const reload = () => {
14+
router.reload({
15+
only: ['mixed'],
16+
})
17+
}
18+
</script>
19+
20+
<template>
21+
<div>
22+
<div>name is {{ mixed.name }}</div>
23+
<div>users: {{ mixed.users.join(', ') }}</div>
24+
<div>chat.data: {{ mixed.chat.data.join(', ') }}</div>
25+
<div>post.id: {{ mixed.post.id }}</div>
26+
<div>post.comments.allowed: {{ mixed.post.comments.allowed ? 'true' : 'false' }}</div>
27+
<div>post.comments.data: {{ mixed.post.comments.data.join(', ') }}</div>
28+
<button @click="reload">Reload</button>
29+
</div>
30+
</template>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<script setup lang="ts">
2+
import { router } from '@inertiajs/vue3'
3+
4+
const props = defineProps<{
5+
users: { data: { id: number; name: string }[]; meta: { page: number; perPage: number } }
6+
}>()
7+
8+
const loadMore = () => {
9+
router.reload({
10+
only: ['users'],
11+
data: { page: props.users.meta.page + 1 },
12+
})
13+
}
14+
</script>
15+
16+
<template>
17+
<div>
18+
<p id="users">{{ users.data.map((user) => user.name).join(', ') }}</p>
19+
<p id="meta">Page: {{ users.meta.page }}, Per Page: {{ users.meta.perPage }}</p>
20+
<button @click="loadMore">Load More</button>
21+
</div>
22+
</template>

0 commit comments

Comments
 (0)