diff --git a/packages/core/src/response.ts b/packages/core/src/response.ts index 7623ec45e..c787498e2 100644 --- a/packages/core/src/response.ts +++ b/packages/core/src/response.ts @@ -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), @@ -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() + + 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] +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a301f06a6..d1df8bb01 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -63,6 +63,7 @@ export interface Page { deferredProps?: Record mergeProps?: string[] deepMergeProps?: string[] + mergeStrategies?: string[] /** @internal */ rememberedState: Record diff --git a/packages/react/test-app/Pages/MergeStrategies.jsx b/packages/react/test-app/Pages/MergeStrategies.jsx new file mode 100644 index 000000000..4697e9145 --- /dev/null +++ b/packages/react/test-app/Pages/MergeStrategies.jsx @@ -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 ( + <> +
bar count is {bar.length}
+
baz count is {baz.length}
+
foo.data count is {foo.data.length}
+
first foo.data name is {foo.data[0].name}
+
last foo.data name is {foo.data[foo.data.length - 1].name}
+
foo.companies count is {foo.companies.length}
+
first foo.companies name is {foo.companies[0].name}
+
last foo.companies name is {foo.companies[foo.companies.length - 1].name}
+
foo.teams count is {foo.teams.length}
+
first foo.teams name is {foo.teams[0].name}
+
last foo.teams name is {foo.teams[foo.teams.length - 1].name}
+
foo.page is {foo.page}
+
foo.per_page is {foo.per_page}
+
foo.meta.label is {foo.meta.label}
+ + + + ) +} diff --git a/packages/svelte/test-app/Pages/MergeStrategies.svelte b/packages/svelte/test-app/Pages/MergeStrategies.svelte new file mode 100644 index 000000000..aa14e929e --- /dev/null +++ b/packages/svelte/test-app/Pages/MergeStrategies.svelte @@ -0,0 +1,45 @@ + + +
bar count is {bar.length}
+
baz count is {baz.length}
+
foo.data count is {foo.data.length}
+
first foo.data name is {foo.data[0].name}
+
last foo.data name is {foo.data[foo.data.length - 1].name}
+
foo.companies count is {foo.companies.length}
+
first foo.companies name is {foo.companies[0].name}
+
last foo.companies name is {foo.companies[foo.companies.length - 1].name}
+
foo.teams count is {foo.teams.length}
+
first foo.teams name is {foo.teams[0].name}
+
last foo.teams name is {foo.teams[foo.teams.length - 1].name}
+
foo.page is {foo.page}
+
foo.per_page is {foo.per_page}
+
foo.meta.label is {foo.meta.label}
+ + diff --git a/packages/vue3/src/app.ts b/packages/vue3/src/app.ts index 4e7a39c34..97658ea64 100755 --- a/packages/vue3/src/app.ts +++ b/packages/vue3/src/app.ts @@ -136,6 +136,7 @@ export function usePage(): Page { 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), }) diff --git a/packages/vue3/test-app/Pages/MergeStrategies.vue b/packages/vue3/test-app/Pages/MergeStrategies.vue new file mode 100644 index 000000000..5b04889e0 --- /dev/null +++ b/packages/vue3/test-app/Pages/MergeStrategies.vue @@ -0,0 +1,55 @@ + + + diff --git a/tests/app/server.js b/tests/app/server.js index dae359fca..0b76179d6 100644 --- a/tests/app/server.js +++ b/tests/app/server.js @@ -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, { diff --git a/tests/merge-strategies.spec.ts b/tests/merge-strategies.spec.ts new file mode 100644 index 000000000..5e56579d1 --- /dev/null +++ b/tests/merge-strategies.spec.ts @@ -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() +})