Skip to content

Commit e948b6f

Browse files
committed
Deep merge shared props in ResponseFactory
- Add DeepMergesSharedProps trait to flatten and deep merge overlapping shared props within a single Inertia response - Update ResponseFactory to apply deep merging when rendering components and sharing props
1 parent 6e7606f commit e948b6f

File tree

4 files changed

+350
-8
lines changed

4 files changed

+350
-8
lines changed

src/DeepMergesSharedProps.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
namespace Inertia;
4+
5+
use Closure;
6+
use Illuminate\Contracts\Support\Arrayable;
7+
use Illuminate\Support\Arr;
8+
use ReflectionFunction;
9+
10+
trait DeepMergesSharedProps
11+
{
12+
/**
13+
* Recursively merges multiple shared Inertia props within the current request.
14+
* This method ensures that overlapping keys between multiple sets of props
15+
* are merged deeply instead of overwritten, preserving nested structures.
16+
*/
17+
protected function deepMergeSharedProps(array $props, array $sharedProps = []): array
18+
{
19+
foreach ($props as $key => $prop) {
20+
$propArray = $this->attemptArrayCast($prop);
21+
$sharedPropArray = $this->attemptArrayCast(Arr::get($sharedProps, $key));
22+
23+
$shouldFlattenPropArray = is_int($key) && is_array($propArray);
24+
if ($shouldFlattenPropArray) {
25+
$sharedProps = $this->deepMergeSharedProps($propArray, $sharedProps);
26+
27+
continue;
28+
}
29+
30+
$shouldOverride = ! is_array($propArray) || ! is_array($sharedPropArray);
31+
if ($shouldOverride) {
32+
Arr::set($sharedProps, $key, $propArray);
33+
34+
continue;
35+
}
36+
37+
$shouldConcatenate = $this->isIndexedArray($propArray) && $this->isIndexedArray($sharedPropArray);
38+
if ($shouldConcatenate) {
39+
Arr::set($sharedProps, $key, array_merge($sharedPropArray, $propArray));
40+
41+
continue;
42+
}
43+
44+
Arr::set($sharedProps, $key, $this->deepMergeSharedProps($propArray, $sharedPropArray));
45+
}
46+
47+
return $sharedProps;
48+
}
49+
50+
protected function isIndexedArray(array $array): bool
51+
{
52+
return array_keys($array) === range(0, count($array) - 1);
53+
}
54+
55+
protected function attemptArrayCast(mixed $value): mixed
56+
{
57+
if ($value instanceof Closure) {
58+
$reflection = new ReflectionFunction($value);
59+
60+
if (! $reflection->getNumberOfRequiredParameters()) {
61+
$value = call_user_func($value);
62+
}
63+
}
64+
65+
if ($value instanceof Arrayable) {
66+
return $value->toArray();
67+
}
68+
69+
return $value;
70+
}
71+
}

src/ResponseFactory.php

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
class ResponseFactory
1818
{
19+
use DeepMergesSharedProps;
1920
use Macroable;
2021

2122
/** @var string */
@@ -46,13 +47,13 @@ public function setRootView(string $name): void
4647
*/
4748
public function share($key, $value = null): void
4849
{
49-
if (is_array($key)) {
50-
$this->sharedProps = array_merge($this->sharedProps, $key);
51-
} elseif ($key instanceof Arrayable) {
52-
$this->sharedProps = array_merge($this->sharedProps, $key->toArray());
53-
} else {
54-
Arr::set($this->sharedProps, $key, $value);
55-
}
50+
$value = match (true) {
51+
is_string($key) => [$key => $value],
52+
is_array($key) => $key,
53+
$key instanceof Arrayable => $value->toArray(),
54+
};
55+
56+
$this->sharedProps = $this->deepMergeSharedProps($value, $this->sharedProps);
5657
}
5758

5859
/**
@@ -159,7 +160,7 @@ public function render(string $component, $props = []): Response
159160

160161
return new Response(
161162
$component,
162-
array_merge($this->sharedProps, $props),
163+
$this->deepMergeSharedProps($props, $this->sharedProps),
163164
$this->rootView,
164165
$this->getVersion(),
165166
$this->encryptHistory ?? config('inertia.history.encrypt', false),

tests/DeepMergesSharedPropsTest.php

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
<?php
2+
3+
namespace Inertia\Tests;
4+
5+
use Illuminate\Support\Arr;
6+
use Illuminate\Support\Collection;
7+
use Inertia\DeepMergesSharedProps;
8+
use Inertia\Tests\Stubs\FakeResource;
9+
10+
class DeepMergesSharedPropsTest extends TestCase
11+
{
12+
protected object $sharedPropsDeepMerger;
13+
14+
protected function setUp(): void
15+
{
16+
parent::setUp();
17+
18+
$this->sharedPropsDeepMerger = new class
19+
{
20+
use DeepMergesSharedProps;
21+
22+
public function handle(array $props, array $sharedProps = []): array
23+
{
24+
return $this->deepMergeSharedProps($props, $sharedProps);
25+
}
26+
};
27+
}
28+
29+
public function test_it_merges_props(): void
30+
{
31+
$props = ['auth.user.can' => ['edit']];
32+
$sharedProps = [];
33+
Arr::set($sharedProps, 'auth.user.can', ['view']);
34+
35+
$result = $this->sharedPropsDeepMerger->handle($props, $sharedProps);
36+
37+
$this->assertEquals([
38+
'auth' => [
39+
'user' => [
40+
'can' => [
41+
'view',
42+
'edit',
43+
],
44+
],
45+
],
46+
], $result);
47+
}
48+
49+
public function test_it_adds_props_with_different_keys_without_merging(): void
50+
{
51+
$props = ['user' => ['John Doe']];
52+
$sharedProps = ['page' => ['user.show']];
53+
54+
$result = $this->sharedPropsDeepMerger->handle($props, $sharedProps);
55+
56+
$this->assertEquals([
57+
'user' => ['John Doe'],
58+
'page' => ['user.show'],
59+
], $result);
60+
}
61+
62+
public function test_it_overrides_existing_values(): void
63+
{
64+
$props = ['page' => 'user.index'];
65+
$sharedProps = ['page' => 'user.show'];
66+
67+
$result = $this->sharedPropsDeepMerger->handle($props, $sharedProps);
68+
69+
$this->assertEquals(['page' => 'user.index'], $result);
70+
}
71+
72+
public function test_it_flattens_nested_props_when_the_root_key_is_not_associative(): void
73+
{
74+
$result = $this->sharedPropsDeepMerger->handle([
75+
'user' => 'John Doe',
76+
['gender' => 'male'],
77+
[['age' => 40]],
78+
'hobbies' => ['tennis', 'chess'],
79+
], ['id' => 1]);
80+
81+
$this->assertEquals([
82+
'id' => 1,
83+
'user' => 'John Doe',
84+
'gender' => 'male',
85+
'age' => 40,
86+
'hobbies' => ['tennis', 'chess'],
87+
], $result);
88+
}
89+
90+
public function test_it_can_handle_an_arrayable(): void
91+
{
92+
$arrayable = new Collection([
93+
'auth.user' => 'John Doe',
94+
]);
95+
96+
$result = $this->sharedPropsDeepMerger->handle([$arrayable]);
97+
98+
$this->assertEquals([
99+
'auth' => [
100+
'user' => 'John Doe',
101+
],
102+
], $result);
103+
}
104+
105+
public function test_it_can_merge_arrayables(): void
106+
{
107+
$result = $this->sharedPropsDeepMerger->handle(
108+
[
109+
'auth.user' => new Collection(['name' => 'John Doe']),
110+
],
111+
[
112+
'auth' => [
113+
'user' => new Collection(['id' => 1]),
114+
],
115+
],
116+
);
117+
118+
$this->assertEquals([
119+
'auth' => [
120+
'user' => [
121+
'id' => 1,
122+
'name' => 'John Doe',
123+
],
124+
],
125+
], $result);
126+
}
127+
128+
public function test_it_can_merge_callables(): void
129+
{
130+
$result = $this->sharedPropsDeepMerger->handle(
131+
[
132+
'auth' => fn (): array => [
133+
'user' => [
134+
'name' => 'John Doe',
135+
],
136+
],
137+
],
138+
[
139+
'auth' => fn (): array => [
140+
'user' => [
141+
'id' => 1,
142+
],
143+
],
144+
],
145+
);
146+
147+
$this->assertEquals([
148+
'auth' => [
149+
'user' => [
150+
'id' => 1,
151+
'name' => 'John Doe',
152+
],
153+
],
154+
], $result);
155+
}
156+
157+
public function test_it_overrides_values_with_unmergeable_types(): void
158+
{
159+
$props = [
160+
'resource' => new FakeResource(['Replacement']),
161+
'scalar' => 'Replacement',
162+
'uncallable' => fn (string $value): string => 'Replacement',
163+
];
164+
$sharedProps = [
165+
'resource' => new FakeResource(['Original']),
166+
'scalar' => 'Original',
167+
'uncallable' => fn (string $value): string => 'Original',
168+
];
169+
170+
$result = $this->sharedPropsDeepMerger->handle($props, $sharedProps);
171+
172+
$this->assertEquals($props, $result);
173+
}
174+
175+
public function test_it_deep_merges_arrayables_and_arrays(): void
176+
{
177+
$result = $this->sharedPropsDeepMerger->handle([
178+
new Collection([
179+
'auth.user' => [
180+
'name' => 'John Doe',
181+
],
182+
]),
183+
[
184+
'auth.user.can' => [
185+
'edit_profile',
186+
],
187+
],
188+
], [
189+
'auth' => [
190+
'user' => new Collection([
191+
'id' => 1,
192+
'can' => new Collection(['delete_profile']),
193+
]),
194+
],
195+
]);
196+
197+
$this->assertEquals([
198+
'auth' => [
199+
'user' => [
200+
'name' => 'John Doe',
201+
'id' => 1,
202+
'can' => [
203+
'delete_profile',
204+
'edit_profile',
205+
],
206+
],
207+
],
208+
], $result);
209+
}
210+
211+
public function test_it_flattens_and_merges_nested_props(): void
212+
{
213+
$result = $this->sharedPropsDeepMerger->handle([
214+
[
215+
'auth.user.can.manage_profiles' => true,
216+
],
217+
], [
218+
'auth' => [
219+
'user' => [
220+
'can' => [
221+
'edit_profile' => false,
222+
'delete_profile' => false,
223+
],
224+
],
225+
],
226+
]);
227+
228+
$this->assertEquals([
229+
'auth' => [
230+
'user' => [
231+
'can' => [
232+
'edit_profile' => false,
233+
'delete_profile' => false,
234+
'manage_profiles' => true,
235+
],
236+
],
237+
],
238+
], $result);
239+
}
240+
}

tests/ResponseFactoryTest.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,36 @@ public function test_shared_data_can_be_shared_from_anywhere(): void
146146
]);
147147
}
148148

149+
public function test_shared_data_can_be_merged(): void
150+
{
151+
Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () {
152+
Inertia::share('auth.user.can.access_user_management', true);
153+
Inertia::share('auth.user.can.delete_user', false);
154+
155+
return Inertia::render('User/Show', [
156+
'auth.user.can' => ['edit_user' => false],
157+
]);
158+
});
159+
160+
$response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']);
161+
162+
$response->assertSuccessful();
163+
$response->assertJson([
164+
'component' => 'User/Show',
165+
'props' => [
166+
'auth' => [
167+
'user' => [
168+
'can' => [
169+
'access_user_management' => true,
170+
'delete_user' => false,
171+
'edit_user' => false,
172+
],
173+
],
174+
],
175+
],
176+
]);
177+
}
178+
149179
public function test_dot_props_are_merged_from_shared(): void
150180
{
151181
Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () {

0 commit comments

Comments
 (0)