diff --git a/packages/core/src/response.ts b/packages/core/src/response.ts index bec91b6f1..1398833be 100644 --- a/packages/core/src/response.ts +++ b/packages/core/src/response.ts @@ -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' @@ -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), + 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 } @@ -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() + // 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, + 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, + 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 { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index be8b8d753..0ce72546c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -118,6 +118,7 @@ export interface Page { encryptHistory: boolean deferredProps?: Record mergeProps?: string[] + prependProps?: string[] deepMergeProps?: string[] matchPropsOn?: string[] diff --git a/packages/react/test-app/Pages/ComplexMergeSelective.tsx b/packages/react/test-app/Pages/ComplexMergeSelective.tsx new file mode 100644 index 000000000..76eac4c4b --- /dev/null +++ b/packages/react/test-app/Pages/ComplexMergeSelective.tsx @@ -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 ( +
+
name is {mixed.name}
+
users: {mixed.users.join(', ')}
+
chat.data: {mixed.chat.data.join(', ')}
+
post.id: {mixed.post.id}
+
post.comments.allowed: {mixed.post.comments.allowed ? 'true' : 'false'}
+
post.comments.data: {mixed.post.comments.data.join(', ')}
+ +
+ ) +} diff --git a/packages/react/test-app/Pages/MergeNestedProps.tsx b/packages/react/test-app/Pages/MergeNestedProps.tsx new file mode 100644 index 000000000..79ef1b221 --- /dev/null +++ b/packages/react/test-app/Pages/MergeNestedProps.tsx @@ -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 ( +
+

{users.data.map((user) => user.name).join(', ')}

+

+ Page: {users.meta.page}, Per Page: {users.meta.perPage} +

+ +
+ ) +} diff --git a/packages/svelte/test-app/Pages/ComplexMergeSelective.svelte b/packages/svelte/test-app/Pages/ComplexMergeSelective.svelte new file mode 100644 index 000000000..352c9f3da --- /dev/null +++ b/packages/svelte/test-app/Pages/ComplexMergeSelective.svelte @@ -0,0 +1,24 @@ + + +
name is {mixed.name}
+
users: {mixed.users.join(', ')}
+
chat.data: {mixed.chat.data.join(', ')}
+
post.id: {mixed.post.id}
+
post.comments.allowed: {mixed.post.comments.allowed ? 'true' : 'false'}
+
post.comments.data: {mixed.post.comments.data.join(', ')}
+ diff --git a/packages/svelte/test-app/Pages/MergeNestedProps.svelte b/packages/svelte/test-app/Pages/MergeNestedProps.svelte new file mode 100644 index 000000000..5b15ed6b7 --- /dev/null +++ b/packages/svelte/test-app/Pages/MergeNestedProps.svelte @@ -0,0 +1,21 @@ + + +
+

{users.data.map((user) => user.name).join(', ')}

+

Page: {users.meta.page}, Per Page: {users.meta.perPage}

+ +
diff --git a/packages/vue3/src/app.ts b/packages/vue3/src/app.ts index 51340187a..c85b4a5f5 100755 --- a/packages/vue3/src/app.ts +++ b/packages/vue3/src/app.ts @@ -135,6 +135,7 @@ export function usePage(): Page { 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), diff --git a/packages/vue3/test-app/Pages/ComplexMergeSelective.vue b/packages/vue3/test-app/Pages/ComplexMergeSelective.vue new file mode 100644 index 000000000..332185d4e --- /dev/null +++ b/packages/vue3/test-app/Pages/ComplexMergeSelective.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/vue3/test-app/Pages/MergeNestedProps.vue b/packages/vue3/test-app/Pages/MergeNestedProps.vue new file mode 100644 index 000000000..5a851b79d --- /dev/null +++ b/packages/vue3/test-app/Pages/MergeNestedProps.vue @@ -0,0 +1,22 @@ + + + diff --git a/tests/app/server.js b/tests/app/server.js index 1a79049d9..85eda5f82 100644 --- a/tests/app/server.js +++ b/tests/app/server.js @@ -374,6 +374,66 @@ app.get('/merge-props', (req, res) => { }) }) +app.get('/merge-nested-props/:strategy', (req, res) => { + const perPage = 3 + const page = parseInt(req.query.page ?? 1) + const shouldAppend = req.params.strategy === 'append' + + const users = new Array(perPage).fill(1).map((_, index) => ({ + id: index + 1 + (page - 1) * perPage, + name: `User ${index + 1 + (page - 1) * perPage}`, + })) + + inertia.render(req, res, { + component: 'MergeNestedProps', + props: { + users: { + data: shouldAppend ? users : users.slice().reverse(), + meta: { + perPage, + page, + }, + }, + }, + ...(req.headers['x-inertia-reset'] + ? {} + : shouldAppend + ? { mergeProps: ['users.data'] } + : { prependProps: ['users.data'] }), + }) +}) + +app.get('/merge-nested-props-with-match/:strategy', (req, res) => { + const page = parseInt(req.query.page ?? 1) + const shouldAppend = req.params.strategy === 'append' + + let users + + if (page === 1) { + users = (shouldAppend ? [1, 2, 3, 4, 5] : [4, 5, 6, 7, 8]).map((id) => ({ id, name: `User ${id} - initial` })) + } else { + users = (shouldAppend ? [4, 5, 6, 7, 8] : [1, 2, 3, 4, 5]).map((id) => ({ id, name: `User ${id} - subsequent` })) + } + + inertia.render(req, res, { + component: 'MergeNestedProps', + props: { + users: { + data: users, + meta: { + perPage: 5, + page, + }, + }, + }, + ...(req.headers['x-inertia-reset'] + ? {} + : shouldAppend + ? { mergeProps: ['users.data'], matchPropsOn: ['users.data.id'] } + : { prependProps: ['users.data'], matchPropsOn: ['users.data.id'] }), + }) +}) + app.get('/deep-merge-props', (req, res) => { const labels = ['first', 'second', 'third', 'fourth', 'fifth'] @@ -397,6 +457,31 @@ app.get('/deep-merge-props', (req, res) => { }) }) +app.get('/complex-merge-selective', (req, res) => { + const isReload = req.headers['x-inertia-partial-component'] || req.headers['x-inertia-partial-data'] + + inertia.render(req, res, { + component: 'ComplexMergeSelective', + props: { + mixed: { + name: isReload ? 'Jane' : 'John', + users: isReload ? ['d', 'e', 'f'] : ['a', 'b', 'c'], + chat: { + data: isReload ? [4, 5, 6] : [1, 2, 3], + }, + post: { + id: 1, + comments: { + allowed: isReload ? false : true, + data: isReload ? ['D', 'E', 'F'] : ['A', 'B', 'C'], + }, + }, + }, + }, + mergeProps: ['mixed.chat.data', 'mixed.post.comments.data'], + }) +}) + app.get('/match-props-on-key', (req, res) => { const labels = ['first', 'second', 'third', 'fourth', 'fifth'] diff --git a/tests/merge-props.spec.ts b/tests/merge-props.spec.ts index e917b9cca..6617c3f8a 100644 --- a/tests/merge-props.spec.ts +++ b/tests/merge-props.spec.ts @@ -27,3 +27,93 @@ test('can merge props', async ({ page }) => { await expect(page.getByText('bar count is 5')).toBeVisible() await expect(page.getByText('foo count is 10')).toBeVisible() }) + +test('can append to nested props', async ({ page }) => { + await page.goto('/merge-nested-props/append') + + await expect(page.getByText('User 1, User 2, User 3')).toBeVisible() + await expect(page.getByText('Page: 1, Per Page: 3')).toBeVisible() + + await clickAndWaitForResponse(page, 'Load More', page.url() + '?page=2', 'button') + + await expect(page.getByText('User 1, User 2, User 3, User 4, User 5, User 6')).toBeVisible() + await expect(page.getByText('Page: 2, Per Page: 3')).toBeVisible() + + await clickAndWaitForResponse(page, 'Load More', page.url() + '?page=3', 'button') + + await expect(page.getByText('User 1, User 2, User 3, User 4, User 5, User 6, User 7, User 8, User 9')).toBeVisible() + await expect(page.getByText('Page: 3, Per Page: 3')).toBeVisible() +}) + +test('can prepend to nested props', async ({ page }) => { + await page.goto('/merge-nested-props/prepend') + + await expect(page.getByText('User 3, User 2, User 1')).toBeVisible() + await expect(page.getByText('Page: 1, Per Page: 3')).toBeVisible() + + await clickAndWaitForResponse(page, 'Load More', page.url() + '?page=2', 'button') + + await expect(page.getByText('User 6, User 5, User 4, User 3, User 2, User 1')).toBeVisible() + await expect(page.getByText('Page: 2, Per Page: 3')).toBeVisible() + + await clickAndWaitForResponse(page, 'Load More', page.url() + '?page=3', 'button') + + await expect(page.getByText('User 9, User 8, User 7, User 6, User 5, User 4, User 3, User 2, User 1')).toBeVisible() + await expect(page.getByText('Page: 3, Per Page: 3')).toBeVisible() +}) + +test('can append to nested props with matchOn', async ({ page }) => { + await page.goto('/merge-nested-props-with-match/append') + + await expect( + page.getByText('User 1 - initial, User 2 - initial, User 3 - initial, User 4 - initial, User 5 - initial'), + ).toBeVisible() + await expect(page.getByText('Page: 1, Per Page: 5')).toBeVisible() + + await clickAndWaitForResponse(page, 'Load More', page.url() + '?page=2', 'button') + + await expect( + page.getByText( + 'User 1 - initial, User 2 - initial, User 3 - initial, User 4 - subsequent, User 5 - subsequent, User 6 - subsequent, User 7 - subsequent, User 8 - subsequent', + ), + ).toBeVisible() + await expect(page.getByText('Page: 2, Per Page: 5')).toBeVisible() +}) + +test('can prepend to nested props with matchOn', async ({ page }) => { + await page.goto('/merge-nested-props-with-match/prepend') + + await expect( + page.getByText('User 4 - initial, User 5 - initial, User 6 - initial, User 7 - initial, User 8 - initial'), + ).toBeVisible() + await expect(page.getByText('Page: 1, Per Page: 5')).toBeVisible() + + await clickAndWaitForResponse(page, 'Load More', page.url() + '?page=2', 'button') + + await expect( + page.getByText( + 'User 1 - subsequent, User 2 - subsequent, User 3 - subsequent, User 4 - subsequent, User 5 - subsequent, User 6 - initial, User 7 - initial, User 8 - initial', + ), + ).toBeVisible() + await expect(page.getByText('Page: 2, Per Page: 5')).toBeVisible() +}) + +test('can selectively merge nested props in complex object', async ({ page }) => { + await page.goto('/complex-merge-selective') + + await expect(page.getByText('name is John')).toBeVisible() + await expect(page.getByText('users: a, b, c')).toBeVisible() + await expect(page.getByText('chat.data: 1, 2, 3')).toBeVisible() + await expect(page.getByText('post.id: 1')).toBeVisible() + await expect(page.getByText('post.comments.allowed: true')).toBeVisible() + await expect(page.getByText('post.comments.data: A, B, C')).toBeVisible() + + await clickAndWaitForResponse(page, 'Reload', null, 'button') + + await expect(page.getByText('name is Jane')).toBeVisible() + await expect(page.getByText('users: d, e, f')).toBeVisible() + await expect(page.getByText('chat.data: 1, 2, 3, 4, 5, 6')).toBeVisible() + await expect(page.getByText('post.id: 1')).toBeVisible() + await expect(page.getByText('post.comments.allowed: false')).toBeVisible() + await expect(page.getByText('post.comments.data: A, B, C, D, E, F')).toBeVisible() +})