Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
$container->register('ux.live_component.live_url_subscriber', LiveUrlSubscriber::class)
->setArguments([
new Reference('ux.live_component.metadata_factory'),
new Reference('ux.live_component.component_hydrator'),
new Reference('ux.live_component.url_factory'),
])
->addTag('kernel.event_subscriber')
Expand Down
35 changes: 21 additions & 14 deletions src/LiveComponent/src/EventListener/LiveUrlSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@
namespace Symfony\UX\LiveComponent\EventListener;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\UX\LiveComponent\LiveComponentHydrator;
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
use Symfony\UX\LiveComponent\Util\UrlFactory;
use Symfony\UX\TwigComponent\MountedComponent;

/**
* @internal
Expand All @@ -27,6 +28,7 @@ class LiveUrlSubscriber implements EventSubscriberInterface

public function __construct(
private LiveComponentMetadataFactory $metadataFactory,
private LiveComponentHydrator $liveComponentHydrator,
private UrlFactory $urlFactory,
) {
}
Expand All @@ -42,9 +44,14 @@ public function onKernelResponse(ResponseEvent $event): void
return;
}

if (!$request->attributes->has('_mounted_component')) {
return;
}

$newLiveUrl = null;
if ($previousLiveUrl = $request->headers->get(self::URL_HEADER)) {
$liveProps = $this->getLivePropsFromRequest($request);
$mounted = $request->attributes->get('_mounted_component');
$liveProps = $this->getLiveProps($mounted);
$newLiveUrl = $this->urlFactory->createFromPreviousAndProps($previousLiveUrl, $liveProps['path'], $liveProps['query']);
}

Expand All @@ -66,26 +73,26 @@ public static function getSubscribedEvents(): array
* query: array<string, mixed>
* }
*/
private function getLivePropsFromRequest(Request $request): array
private function getLiveProps(MountedComponent $mounted): array
{
$componentName = $request->attributes->get('_live_component');
$metadata = $this->metadataFactory->getMetadata($componentName);

$liveRequestData = $request->attributes->get('_live_request_data') ?? [];
$values = array_merge(
$liveRequestData['props'] ?? [],
$liveRequestData['updated'] ?? [],
$liveRequestData['responseProps'] ?? []
$metadata = $this->metadataFactory->getMetadata($mounted->getName());

$dehydratedProps = $this->liveComponentHydrator->dehydrate(
$mounted->getComponent(),
$mounted->getAttributes(),
$metadata
);

$values = $dehydratedProps->getProps();
Comment on lines +78 to +86
Copy link
Member Author

@Kocal Kocal Aug 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the fix for the first bug


$urlLiveProps = [
'path' => [],
'query' => [],
];
foreach ($metadata->getAllUrlMappings() as $name => $urlMapping) {

foreach ($metadata->getAllUrlMappings($mounted->getComponent()) as $name => $urlMapping) {
if (isset($values[$name]) && $urlMapping) {
$urlLiveProps[$urlMapping->mapPath ? 'path' : 'query'][$urlMapping->as ?? $name] =
$values[$name];
$urlLiveProps[$urlMapping->mapPath ? 'path' : 'query'][$urlMapping->as ?? $name] = $values[$name];
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/LiveComponent/src/Metadata/LiveComponentMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,10 @@ public function getOnlyPropsThatAcceptUpdatesFromParent(array $inputProps): arra
/**
* @return UrlMapping[]
*/
public function getAllUrlMappings(): iterable
public function getAllUrlMappings(object $component): iterable
{
$urlMappings = [];
foreach ($this->livePropsMetadata as $livePropMetadata) {
foreach ($this->getAllLivePropsMetadata($component) as $livePropMetadata) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the fix for the 2nd bug

if ($livePropMetadata->urlMapping()) {
$urlMappings[$livePropMetadata->getName()] = $livePropMetadata->urlMapping();
}
Expand All @@ -81,7 +81,7 @@ public function getAllUrlMappings(): iterable
return $urlMappings;
}

public function hasQueryStringBindings($component): bool
public function hasQueryStringBindings(object $component): bool
{
foreach ($this->getAllLivePropsMetadata($component) as $livePropMetadata) {
if ($livePropMetadata->urlMapping()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;

use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\Metadata\UrlMapping;
Expand All @@ -37,6 +39,9 @@ class ComponentWithUrlBoundProps
#[LiveProp(url: true)]
public ?Address $objectProp = null;

#[LiveProp(url: true, useSerializerForHydration: true)]
public ?Address $objectPropWithSerializerForHydration = null;

#[LiveProp(fieldName: 'field1', url: true)]
public ?string $propWithField1 = null;

Expand All @@ -49,6 +54,19 @@ class ComponentWithUrlBoundProps
#[LiveProp]
public ?bool $maybeBoundPropInUrl = false;

#[LiveProp(url: new UrlMapping(as: 'p'), modifier: 'modifyPropertyWithModifierAndAlias')]
public ?string $propertyWithModifierAndAlias = null;

public function modifyPropertyWithModifierAndAlias(LiveProp $liveProp): LiveProp
{
$urlMapping = $liveProp->url();
if (!$urlMapping instanceof UrlMapping) {
return $liveProp;
}

return $liveProp->withUrl(new UrlMapping(as: 'alias_' . $urlMapping->as));
}

public function getField2(): string
{
return 'field2';
Expand Down Expand Up @@ -83,5 +101,13 @@ public function modifyBoundPropWithCustomAlias(LiveProp $liveProp): LiveProp
return $liveProp;
}

#[LiveAction]
public function updateLiveProp(#[LiveArg] string $propName, #[LiveArg] mixed $propValue): void
{
if (!property_exists($this, $propName)) {
throw new \InvalidArgumentException(\sprintf('Property "%s" does not exist on component "%s".', $propName, static::class));
}

$this->{$propName} = $propValue;
}
}
6 changes: 5 additions & 1 deletion src/LiveComponent/tests/Fixtures/Dto/Address.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@

namespace Symfony\UX\LiveComponent\Tests\Fixtures\Dto;

use Symfony\Component\Serializer\Attribute\SerializedName;

class Address
{
public string $address;

#[SerializedName(serializedName: 'c')]
public string $city;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ public function testQueryStringMappingAttribute()
'boundPropWithCustomAlias' => ['name' => 'customAlias'],
'pathProp' => ['name' => 'pathProp'],
'pathPropWithAlias' => ['name' => 'pathAlias'],
'objectPropWithSerializerForHydration' => ['name' => 'objectPropWithSerializerForHydration'],
'propertyWithModifierAndAlias' => ['name' => 'alias_p'],
];

$this->assertEquals($expected, $queryMapping);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Symfony\UX\LiveComponent\Tests\Functional\EventListener;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Address;
use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper;
use Zenstruck\Browser\Test\HasBrowser;

Expand All @@ -26,17 +27,20 @@ public function getTestData(): iterable
'previousLocation' => null,
'expectedLocation' => null,
'props' => [],
'args' => [],
];
yield 'unknown_previous_location' => [
'previousLocation' => 'foo/bar',
'expectedLocation' => null,
'props' => [],
'args' => [],
];

yield 'no_prop' => [
'previousLocation' => '/route_with_prop/foo',
'expectedLocation' => null,
'props' => [],
'args' => [],
];

yield 'no_change' => [
Expand All @@ -45,52 +49,118 @@ public function getTestData(): iterable
'props' => [
'pathProp' => 'foo',
],
'args' => [],
];

yield 'prop_changed' => [
yield 'path_prop_changed' => [
'previousLocation' => '/route_with_prop/foo',
'expectedLocation' => '/route_with_prop/bar',
'props' => [
'pathProp' => 'foo',
],
'updated' => [
'pathProp' => 'bar',
'args' => [
'propName' => 'pathProp',
'propValue' => 'bar',
],
];

yield 'alias_prop_changed' => [
yield 'path_alias_prop_changed' => [
'previousLocation' => '/route_with_alias_prop/foo',
'expectedLocation' => '/route_with_alias_prop/bar',
'props' => [
'pathPropWithAlias' => 'foo',
],
'updated' => [
'pathPropWithAlias' => 'bar',
'args' => [
'propName' => 'pathPropWithAlias',
'propValue' => 'bar',
],
];

yield 'query_alias_prop_changed' => [
'previousLocation' => '/',
'expectedLocation' => '/?q=search%2Bterm',
'props' => [],
'args' => [
'propName' => 'boundPropWithAlias',
'propValue' => 'search+term',
],
];

yield 'path_and_query_alias_prop_changed' => [
'previousLocation' => '/route_with_prop/foo',
'expectedLocation' => '/route_with_prop/baz?q=foo+bar',
'props' => [
'pathProp' => 'baz',
],
'args' => [
'propName' => 'boundPropWithAlias',
'propValue' => 'foo bar',
],
];

$address = new Address();
$address->address = '123 Main St';
$address->city = 'Anytown';
yield 'with an object in query, keys "address" and "city" must be present' => [
'previousLocation' => '/',
'expectedLocation' => '/?objectProp%5Baddress%5D=123+Main+St&objectProp%5Bcity%5D=Anytown&q=search',
'props' => [
'objectProp' => $address,
],
'args' => [
'propName' => 'boundPropWithAlias',
'propValue' => 'search',
],
];

$address = new Address();
$address->address = '123 Main St';
$address->city = 'Anytown';
yield 'with an object in query, with "useSerializerForHydration: true", keys "address" and "c" must be present' => [
'previousLocation' => '/',
'expectedLocation' => '/?intProp=3&objectPropWithSerializerForHydration%5Baddress%5D=123+Main+St&objectPropWithSerializerForHydration%5Bc%5D=Anytown',
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not easy to read, but the c is present at 5Bc%5D

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw it would be nice if we could have a "clean" query, like it was done before.

I tried to play with http_build_query()'s encoding, but it didn't help

'props' => [
'objectPropWithSerializerForHydration' => $address,
],
'args' => [
'propName' => 'intProp',
'propValue' => '3',
],
];

yield 'query with alias ("p") and modifier (prefix by "alias_")' => [
'previousLocation' => '/',
'expectedLocation' => '/?alias_p=test',
'props' => [
'propertyWithModifierAndAlias' => 'test',
],
'args' => [],
];
}

/**
* @dataProvider getTestData
*/
public function testNoHeader(
public function testNewLiveUrlAfterLiveAction(
?string $previousLocation,
?string $expectedLocation,
array $props,
array $updated = [],
array $args,
): void {
$component = $this->mountComponent('component_with_url_bound_props', $props);
$dehydrated = $this->dehydrateComponent($component);

$this->browser()
->throwExceptions()
->post(
'/_components/component_with_url_bound_props',
[] === $args
? '/_components/component_with_url_bound_props'
: '/_components/component_with_url_bound_props/updateLiveProp',
[
'body' => [
'data' => json_encode([
'props' => $dehydrated->getProps(),
'updated' => $updated,
'args' => $args,
]),
],
'headers' => [
Expand Down
Loading
Loading