From 00a5f6544c91ec83e216cee372a00dc09a47658c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Tue, 1 Apr 2025 20:27:48 +0200 Subject: [PATCH 01/10] [WIP] LiveUrl --- .../assets/dist/Backend/BackendResponse.d.ts | 2 + .../assets/dist/live_controller.js | 11 ++ .../assets/src/Backend/BackendResponse.ts | 9 ++ .../assets/src/Backend/RequestBuilder.ts | 1 + .../assets/src/Component/index.ts | 5 + .../LiveComponentExtension.php | 9 ++ .../src/EventListener/LiveUrlSubscriber.php | 133 ++++++++++++++++++ 7 files changed, 170 insertions(+) create mode 100644 src/LiveComponent/src/EventListener/LiveUrlSubscriber.php diff --git a/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts b/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts index a51a6448707..b6fef064a7d 100644 --- a/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts +++ b/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts @@ -1,6 +1,8 @@ export default class { response: Response; private body; + private liveUrl; constructor(response: Response); getBody(): Promise; + getLiveUrl(): Promise; } diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 41177512fdd..7972761d605 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -33,6 +33,7 @@ class RequestBuilder { fetchOptions.headers = { Accept: 'application/vnd.live-component+html', 'X-Requested-With': 'XMLHttpRequest', + 'X-Live-Url': window.location.href }; const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0); const hasFingerprints = Object.keys(children).length > 0; @@ -111,6 +112,12 @@ class BackendResponse { } return this.body; } + async getLiveUrl() { + if (undefined === this.liveUrl) { + this.liveUrl = await this.response.headers.get('X-Live-Url'); + } + return this.liveUrl; + } } function getElementAsTagText(element) { @@ -2137,6 +2144,10 @@ class Component { return response; } this.processRerender(html, backendResponse); + const liveUrl = await backendResponse.getLiveUrl(); + if (liveUrl) { + HistoryStrategy.replace(new UrlUtils(liveUrl)); + } this.backendRequest = null; thisPromiseResolve(backendResponse); if (this.isRequestPending) { diff --git a/src/LiveComponent/assets/src/Backend/BackendResponse.ts b/src/LiveComponent/assets/src/Backend/BackendResponse.ts index 5b1357bd24e..afd963d2e02 100644 --- a/src/LiveComponent/assets/src/Backend/BackendResponse.ts +++ b/src/LiveComponent/assets/src/Backend/BackendResponse.ts @@ -1,6 +1,7 @@ export default class { response: Response; private body: string; + private liveUrl: string | null; constructor(response: Response) { this.response = response; @@ -13,4 +14,12 @@ export default class { return this.body; } + + async getLiveUrl(): Promise { + if (undefined === this.liveUrl) { + this.liveUrl = await this.response.headers.get('X-Live-Url'); + } + + return this.liveUrl; + } } diff --git a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts index 533e34fece9..6bd05769ab8 100644 --- a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts +++ b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts @@ -26,6 +26,7 @@ export default class { fetchOptions.headers = { Accept: 'application/vnd.live-component+html', 'X-Requested-With': 'XMLHttpRequest', + 'X-Live-Url' : window.location.href }; const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0); diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 7db1f564a7b..baab899dbb7 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -11,6 +11,7 @@ import type { ElementDriver } from './ElementDriver'; import UnsyncedInputsTracker from './UnsyncedInputsTracker'; import ValueStore from './ValueStore'; import type { PluginInterface } from './plugins/PluginInterface'; +import {HistoryStrategy, UrlUtils} from "../url_utils"; declare const Turbo: any; @@ -328,6 +329,10 @@ export default class Component { } this.processRerender(html, backendResponse); + const liveUrl = await backendResponse.getLiveUrl(); + if (liveUrl) { + HistoryStrategy.replace(new UrlUtils(liveUrl)); + } // finally resolve this promise this.backendRequest = null; diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index dc04bfb10fa..062dda84e8f 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -33,6 +33,7 @@ use Symfony\UX\LiveComponent\EventListener\DeferLiveComponentSubscriber; use Symfony\UX\LiveComponent\EventListener\InterceptChildComponentRenderSubscriber; use Symfony\UX\LiveComponent\EventListener\LiveComponentSubscriber; +use Symfony\UX\LiveComponent\EventListener\LiveUrlSubscriber; use Symfony\UX\LiveComponent\EventListener\QueryStringInitializeSubscriber; use Symfony\UX\LiveComponent\EventListener\ResetDeterministicIdSubscriber; use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType; @@ -135,6 +136,14 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { ->addTag('container.service_subscriber', ['key' => LiveComponentMetadataFactory::class, 'id' => 'ux.live_component.metadata_factory']) ; + $container->register('ux.live_component.live_url_subscriber', LiveUrlSubscriber::class) + ->setArguments([ + new Reference('router'), + new Reference('ux.live_component.metadata_factory'), + ]) + ->addTag('kernel.event_subscriber') + ; + $container->register('ux.live_component.live_responder', LiveResponder::class); $container->setAlias(LiveResponder::class, 'ux.live_component.live_responder'); diff --git a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php new file mode 100644 index 00000000000..2520c8d4073 --- /dev/null +++ b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php @@ -0,0 +1,133 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +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\Component\Routing\RouterInterface; +use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; + +class LiveUrlSubscriber implements EventSubscriberInterface +{ + private const URL_HEADER = 'X-Live-Url'; + + public function __construct( + private readonly RouterInterface $router, + private readonly LiveComponentMetadataFactory $metadataFactory, + ) { + } + + public function onKernelResponse(ResponseEvent $event): void + { + if (!$this->isLiveComponentRequest($request = $event->getRequest())) { + return; + } + if (!$event->isMainRequest()) { + return; + } + + if ($previousLocation = $request->headers->get(self::URL_HEADER)) { + $newUrl = $this->computeNewUrl( + $previousLocation, + $this->getLivePropsToMap($request) + ); + if ($newUrl) { + $event->getResponse()->headers->set( + self::URL_HEADER, + $newUrl + ); + } + } + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::RESPONSE => 'onKernelResponse', + ]; + } + + private function getLivePropsToMap(Request $request): array + { + $componentName = $request->attributes->get('_live_component'); + $component = $request->attributes->get('_mounted_component'); + $metadata = $this->metadataFactory->getMetadata($componentName); + + $liveData = $request->attributes->get('_live_request_data') ?? []; + $values = array_merge($liveData['props'] ?? [], $liveData['updated'] ?? []); + + $urlLiveProps = []; + foreach ($metadata->getAllLivePropsMetadata($component) as $liveProp) { + $name = $liveProp->getName(); + $urlMapping = $liveProp->urlMapping(); + if (isset($values[$name]) && $urlMapping) { + $urlLiveProps[$urlMapping->as ?? $name] = $values[$name]; + } + } + + return $urlLiveProps; + } + + // @todo use requestStack ? + private function computeNewUrl(string $previousUrl, array $newProps): string + { + $parsed = parse_url($previousUrl); + $baseUrl = $parsed['scheme'].'://'; + if (isset($parsed['user'])) { + $baseUrl .= $parsed['user']; + if (isset($parsed['pass'])) { + $baseUrl .= ':'.$parsed['pass']; + } + $baseUrl .= '@'; + } + $baseUrl .= $parsed['host']; + if (isset($parsed['port'])) { + $baseUrl .= ':'.$parsed['port']; + } + + $path = $parsed['path'] ?? ''; + if (isset($parsed['query'])) { + $path .= '?'.$parsed['query']; + } + parse_str($parsed['query'] ?? '', $previousParams); + + $match = $this->router->match($path); + $newUrl = $this->router->generate( + $match['_route'], + array_merge($previousParams, $newProps) + ); + + $fragment = $parsed['fragment'] ?? ''; + + return $baseUrl.$newUrl.$fragment; + } + + /** + * copied from LiveComponentSubscriber. + */ + private function isLiveComponentRequest(Request $request): bool + { + if (!$request->attributes->has('_live_component')) { + return false; + } + + // if ($this->testMode) { + // return true; + // } + + // Except when testing, require the correct content-type in the Accept header. + // This also acts as a CSRF protection since this can only be set in accordance with same-origin/CORS policies. + return \in_array('application/vnd.live-component+html', $request->getAcceptableContentTypes(), true); + } +} From 4f38b78f2701816e7c4aa2d404a4e8ebf385f335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Sun, 6 Apr 2025 09:00:00 +0200 Subject: [PATCH 02/10] LiveUrlSubscriber should handle only path and query --- .../assets/dist/live_controller.js | 214 +++++++++--------- .../assets/src/Backend/RequestBuilder.ts | 2 +- .../assets/src/Component/index.ts | 2 +- .../Component/plugins/QueryStringPlugin.ts | 1 + .../src/EventListener/LiveUrlSubscriber.php | 30 +-- 5 files changed, 116 insertions(+), 133 deletions(-) diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 7972761d605..48052fe3f35 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -33,7 +33,7 @@ class RequestBuilder { fetchOptions.headers = { Accept: 'application/vnd.live-component+html', 'X-Requested-With': 'XMLHttpRequest', - 'X-Live-Url': window.location.href + 'X-Live-Url': window.location.pathname + window.location.search }; const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0); const hasFingerprints = Object.keys(children).length > 0; @@ -1975,6 +1975,110 @@ class ValueStore { } } +function isValueEmpty(value) { + if (null === value || value === '' || undefined === value || (Array.isArray(value) && value.length === 0)) { + return true; + } + if (typeof value !== 'object') { + return false; + } + for (const key of Object.keys(value)) { + if (!isValueEmpty(value[key])) { + return false; + } + } + return true; +} +function toQueryString(data) { + const buildQueryStringEntries = (data, entries = {}, baseKey = '') => { + Object.entries(data).forEach(([iKey, iValue]) => { + const key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`; + if ('' === baseKey && isValueEmpty(iValue)) { + entries[key] = ''; + } + else if (null !== iValue) { + if (typeof iValue === 'object') { + entries = { ...entries, ...buildQueryStringEntries(iValue, entries, key) }; + } + else { + entries[key] = encodeURIComponent(iValue) + .replace(/%20/g, '+') + .replace(/%2C/g, ','); + } + } + }); + return entries; + }; + const entries = buildQueryStringEntries(data); + return Object.entries(entries) + .map(([key, value]) => `${key}=${value}`) + .join('&'); +} +function fromQueryString(search) { + search = search.replace('?', ''); + if (search === '') + return {}; + const insertDotNotatedValueIntoData = (key, value, data) => { + const [first, second, ...rest] = key.split('.'); + if (!second) { + data[key] = value; + return value; + } + if (data[first] === undefined) { + data[first] = Number.isNaN(Number.parseInt(second)) ? {} : []; + } + insertDotNotatedValueIntoData([second, ...rest].join('.'), value, data[first]); + }; + const entries = search.split('&').map((i) => i.split('=')); + const data = {}; + entries.forEach(([key, value]) => { + value = decodeURIComponent(value.replace(/\+/g, '%20')); + if (!key.includes('[')) { + data[key] = value; + } + else { + if ('' === value) + return; + const dotNotatedKey = key.replace(/\[/g, '.').replace(/]/g, ''); + insertDotNotatedValueIntoData(dotNotatedKey, value, data); + } + }); + return data; +} +class UrlUtils extends URL { + has(key) { + const data = this.getData(); + return Object.keys(data).includes(key); + } + set(key, value) { + const data = this.getData(); + data[key] = value; + this.setData(data); + } + get(key) { + return this.getData()[key]; + } + remove(key) { + const data = this.getData(); + delete data[key]; + this.setData(data); + } + getData() { + if (!this.search) { + return {}; + } + return fromQueryString(this.search); + } + setData(data) { + this.search = toQueryString(data); + } +} +class HistoryStrategy { + static replace(url) { + history.replaceState(history.state, '', url); + } +} + class Component { constructor(element, name, props, listeners, id, backend, elementDriver) { this.fingerprint = ''; @@ -2146,7 +2250,7 @@ class Component { this.processRerender(html, backendResponse); const liveUrl = await backendResponse.getLiveUrl(); if (liveUrl) { - HistoryStrategy.replace(new UrlUtils(liveUrl)); + HistoryStrategy.replace(new UrlUtils(liveUrl + window.location.hash, window.location.origin)); } this.backendRequest = null; thisPromiseResolve(backendResponse); @@ -2752,110 +2856,6 @@ class PollingPlugin { } } -function isValueEmpty(value) { - if (null === value || value === '' || undefined === value || (Array.isArray(value) && value.length === 0)) { - return true; - } - if (typeof value !== 'object') { - return false; - } - for (const key of Object.keys(value)) { - if (!isValueEmpty(value[key])) { - return false; - } - } - return true; -} -function toQueryString(data) { - const buildQueryStringEntries = (data, entries = {}, baseKey = '') => { - Object.entries(data).forEach(([iKey, iValue]) => { - const key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`; - if ('' === baseKey && isValueEmpty(iValue)) { - entries[key] = ''; - } - else if (null !== iValue) { - if (typeof iValue === 'object') { - entries = { ...entries, ...buildQueryStringEntries(iValue, entries, key) }; - } - else { - entries[key] = encodeURIComponent(iValue) - .replace(/%20/g, '+') - .replace(/%2C/g, ','); - } - } - }); - return entries; - }; - const entries = buildQueryStringEntries(data); - return Object.entries(entries) - .map(([key, value]) => `${key}=${value}`) - .join('&'); -} -function fromQueryString(search) { - search = search.replace('?', ''); - if (search === '') - return {}; - const insertDotNotatedValueIntoData = (key, value, data) => { - const [first, second, ...rest] = key.split('.'); - if (!second) { - data[key] = value; - return value; - } - if (data[first] === undefined) { - data[first] = Number.isNaN(Number.parseInt(second)) ? {} : []; - } - insertDotNotatedValueIntoData([second, ...rest].join('.'), value, data[first]); - }; - const entries = search.split('&').map((i) => i.split('=')); - const data = {}; - entries.forEach(([key, value]) => { - value = decodeURIComponent(value.replace(/\+/g, '%20')); - if (!key.includes('[')) { - data[key] = value; - } - else { - if ('' === value) - return; - const dotNotatedKey = key.replace(/\[/g, '.').replace(/]/g, ''); - insertDotNotatedValueIntoData(dotNotatedKey, value, data); - } - }); - return data; -} -class UrlUtils extends URL { - has(key) { - const data = this.getData(); - return Object.keys(data).includes(key); - } - set(key, value) { - const data = this.getData(); - data[key] = value; - this.setData(data); - } - get(key) { - return this.getData()[key]; - } - remove(key) { - const data = this.getData(); - delete data[key]; - this.setData(data); - } - getData() { - if (!this.search) { - return {}; - } - return fromQueryString(this.search); - } - setData(data) { - this.search = toQueryString(data); - } -} -class HistoryStrategy { - static replace(url) { - history.replaceState(history.state, '', url); - } -} - class QueryStringPlugin { constructor(mapping) { this.mapping = mapping; @@ -2869,7 +2869,7 @@ class QueryStringPlugin { urlUtils.set(mapping.name, value); }); if (currentUrl !== urlUtils.toString()) { - HistoryStrategy.replace(urlUtils); + HistoryStrategy.replace(new UrlUtils(currentUrl)); } }); } diff --git a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts index 6bd05769ab8..2792d7fad93 100644 --- a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts +++ b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts @@ -26,7 +26,7 @@ export default class { fetchOptions.headers = { Accept: 'application/vnd.live-component+html', 'X-Requested-With': 'XMLHttpRequest', - 'X-Live-Url' : window.location.href + 'X-Live-Url' : window.location.pathname + window.location.search }; const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0); diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index baab899dbb7..825b34b1296 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -331,7 +331,7 @@ export default class Component { this.processRerender(html, backendResponse); const liveUrl = await backendResponse.getLiveUrl(); if (liveUrl) { - HistoryStrategy.replace(new UrlUtils(liveUrl)); + HistoryStrategy.replace(new UrlUtils(liveUrl + window.location.hash, window.location.origin)); } // finally resolve this promise diff --git a/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts index c0ac2f08849..26ae489c35d 100644 --- a/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts +++ b/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts @@ -12,6 +12,7 @@ interface QueryMapping { export default class implements PluginInterface { constructor(private readonly mapping: { [p: string]: QueryMapping }) {} + //@todo delete attachToComponent(component: Component): void { component.on('render:finished', (component: Component) => { const urlUtils = new UrlUtils(window.location.href); diff --git a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php index 2520c8d4073..659f7f6de65 100644 --- a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php @@ -79,38 +79,20 @@ private function getLivePropsToMap(Request $request): array return $urlLiveProps; } - // @todo use requestStack ? private function computeNewUrl(string $previousUrl, array $newProps): string { $parsed = parse_url($previousUrl); - $baseUrl = $parsed['scheme'].'://'; - if (isset($parsed['user'])) { - $baseUrl .= $parsed['user']; - if (isset($parsed['pass'])) { - $baseUrl .= ':'.$parsed['pass']; - } - $baseUrl .= '@'; - } - $baseUrl .= $parsed['host']; - if (isset($parsed['port'])) { - $baseUrl .= ':'.$parsed['port']; - } - $path = $parsed['path'] ?? ''; + $url = $parsed['path'] ?? ''; if (isset($parsed['query'])) { - $path .= '?'.$parsed['query']; + $url .= '?'.$parsed['query']; } - parse_str($parsed['query'] ?? '', $previousParams); + parse_str($parsed['query'] ?? '', $previousQueryParams); - $match = $this->router->match($path); - $newUrl = $this->router->generate( - $match['_route'], - array_merge($previousParams, $newProps) + return $this->router->generate( + $this->router->match($url)['_route'], + array_merge($previousQueryParams, $newProps) ); - - $fragment = $parsed['fragment'] ?? ''; - - return $baseUrl.$newUrl.$fragment; } /** From ae2f7e31034b3660956340edd4e9fae5a39c877e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Sun, 6 Apr 2025 10:00:00 +0200 Subject: [PATCH 03/10] Remove unused QueryStringPlugin --- .../Component/plugins/QueryStringPlugin.d.ts | 13 - .../assets/dist/live_controller.d.ts | 9 - .../assets/dist/live_controller.js | 231 ++++++++---------- .../assets/src/Backend/RequestBuilder.ts | 2 +- .../assets/src/Component/index.ts | 2 +- .../Component/plugins/QueryStringPlugin.ts | 32 --- .../assets/src/live_controller.ts | 4 - .../test/Backend/RequestBuilder.test.ts | 6 + 8 files changed, 113 insertions(+), 186 deletions(-) delete mode 100644 src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts delete mode 100644 src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts diff --git a/src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts b/src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts deleted file mode 100644 index f91f5e6c871..00000000000 --- a/src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type Component from '../index'; -import type { PluginInterface } from './PluginInterface'; -interface QueryMapping { - name: string; -} -export default class implements PluginInterface { - private readonly mapping; - constructor(mapping: { - [p: string]: QueryMapping; - }); - attachToComponent(component: Component): void; -} -export {}; diff --git a/src/LiveComponent/assets/dist/live_controller.d.ts b/src/LiveComponent/assets/dist/live_controller.d.ts index 7e5cff52474..21a6b186ce8 100644 --- a/src/LiveComponent/assets/dist/live_controller.d.ts +++ b/src/LiveComponent/assets/dist/live_controller.d.ts @@ -49,10 +49,6 @@ export default class LiveControllerDefault extends Controller imple type: StringConstructor; default: string; }; - queryMapping: { - type: ObjectConstructor; - default: {}; - }; }; readonly nameValue: string; readonly urlValue: string; @@ -76,11 +72,6 @@ export default class LiveControllerDefault extends Controller imple readonly debounceValue: number; readonly fingerprintValue: string; readonly requestMethodValue: 'get' | 'post'; - readonly queryMappingValue: { - [p: string]: { - name: string; - }; - }; private proxiedComponent; private mutationObserver; component: Component; diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 48052fe3f35..49497a9a0de 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -33,7 +33,7 @@ class RequestBuilder { fetchOptions.headers = { Accept: 'application/vnd.live-component+html', 'X-Requested-With': 'XMLHttpRequest', - 'X-Live-Url': window.location.pathname + window.location.search + 'X-Live-Url': window.location.pathname + window.location.search, }; const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0); const hasFingerprints = Object.keys(children).length > 0; @@ -1797,6 +1797,110 @@ function executeMorphdom(rootFromElement, rootToElement, modifiedFieldElements, }); } +function isValueEmpty(value) { + if (null === value || value === '' || undefined === value || (Array.isArray(value) && value.length === 0)) { + return true; + } + if (typeof value !== 'object') { + return false; + } + for (const key of Object.keys(value)) { + if (!isValueEmpty(value[key])) { + return false; + } + } + return true; +} +function toQueryString(data) { + const buildQueryStringEntries = (data, entries = {}, baseKey = '') => { + Object.entries(data).forEach(([iKey, iValue]) => { + const key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`; + if ('' === baseKey && isValueEmpty(iValue)) { + entries[key] = ''; + } + else if (null !== iValue) { + if (typeof iValue === 'object') { + entries = { ...entries, ...buildQueryStringEntries(iValue, entries, key) }; + } + else { + entries[key] = encodeURIComponent(iValue) + .replace(/%20/g, '+') + .replace(/%2C/g, ','); + } + } + }); + return entries; + }; + const entries = buildQueryStringEntries(data); + return Object.entries(entries) + .map(([key, value]) => `${key}=${value}`) + .join('&'); +} +function fromQueryString(search) { + search = search.replace('?', ''); + if (search === '') + return {}; + const insertDotNotatedValueIntoData = (key, value, data) => { + const [first, second, ...rest] = key.split('.'); + if (!second) { + data[key] = value; + return value; + } + if (data[first] === undefined) { + data[first] = Number.isNaN(Number.parseInt(second)) ? {} : []; + } + insertDotNotatedValueIntoData([second, ...rest].join('.'), value, data[first]); + }; + const entries = search.split('&').map((i) => i.split('=')); + const data = {}; + entries.forEach(([key, value]) => { + value = decodeURIComponent(value.replace(/\+/g, '%20')); + if (!key.includes('[')) { + data[key] = value; + } + else { + if ('' === value) + return; + const dotNotatedKey = key.replace(/\[/g, '.').replace(/]/g, ''); + insertDotNotatedValueIntoData(dotNotatedKey, value, data); + } + }); + return data; +} +class UrlUtils extends URL { + has(key) { + const data = this.getData(); + return Object.keys(data).includes(key); + } + set(key, value) { + const data = this.getData(); + data[key] = value; + this.setData(data); + } + get(key) { + return this.getData()[key]; + } + remove(key) { + const data = this.getData(); + delete data[key]; + this.setData(data); + } + getData() { + if (!this.search) { + return {}; + } + return fromQueryString(this.search); + } + setData(data) { + this.search = toQueryString(data); + } +} +class HistoryStrategy { + static replace(url) { + history.replaceState(history.state, '', url); + } +} + class UnsyncedInputsTracker { constructor(component, modelElementResolver) { this.elementEventListeners = [ @@ -1975,110 +2079,6 @@ class ValueStore { } } -function isValueEmpty(value) { - if (null === value || value === '' || undefined === value || (Array.isArray(value) && value.length === 0)) { - return true; - } - if (typeof value !== 'object') { - return false; - } - for (const key of Object.keys(value)) { - if (!isValueEmpty(value[key])) { - return false; - } - } - return true; -} -function toQueryString(data) { - const buildQueryStringEntries = (data, entries = {}, baseKey = '') => { - Object.entries(data).forEach(([iKey, iValue]) => { - const key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`; - if ('' === baseKey && isValueEmpty(iValue)) { - entries[key] = ''; - } - else if (null !== iValue) { - if (typeof iValue === 'object') { - entries = { ...entries, ...buildQueryStringEntries(iValue, entries, key) }; - } - else { - entries[key] = encodeURIComponent(iValue) - .replace(/%20/g, '+') - .replace(/%2C/g, ','); - } - } - }); - return entries; - }; - const entries = buildQueryStringEntries(data); - return Object.entries(entries) - .map(([key, value]) => `${key}=${value}`) - .join('&'); -} -function fromQueryString(search) { - search = search.replace('?', ''); - if (search === '') - return {}; - const insertDotNotatedValueIntoData = (key, value, data) => { - const [first, second, ...rest] = key.split('.'); - if (!second) { - data[key] = value; - return value; - } - if (data[first] === undefined) { - data[first] = Number.isNaN(Number.parseInt(second)) ? {} : []; - } - insertDotNotatedValueIntoData([second, ...rest].join('.'), value, data[first]); - }; - const entries = search.split('&').map((i) => i.split('=')); - const data = {}; - entries.forEach(([key, value]) => { - value = decodeURIComponent(value.replace(/\+/g, '%20')); - if (!key.includes('[')) { - data[key] = value; - } - else { - if ('' === value) - return; - const dotNotatedKey = key.replace(/\[/g, '.').replace(/]/g, ''); - insertDotNotatedValueIntoData(dotNotatedKey, value, data); - } - }); - return data; -} -class UrlUtils extends URL { - has(key) { - const data = this.getData(); - return Object.keys(data).includes(key); - } - set(key, value) { - const data = this.getData(); - data[key] = value; - this.setData(data); - } - get(key) { - return this.getData()[key]; - } - remove(key) { - const data = this.getData(); - delete data[key]; - this.setData(data); - } - getData() { - if (!this.search) { - return {}; - } - return fromQueryString(this.search); - } - setData(data) { - this.search = toQueryString(data); - } -} -class HistoryStrategy { - static replace(url) { - history.replaceState(history.state, '', url); - } -} - class Component { constructor(element, name, props, listeners, id, backend, elementDriver) { this.fingerprint = ''; @@ -2856,25 +2856,6 @@ class PollingPlugin { } } -class QueryStringPlugin { - constructor(mapping) { - this.mapping = mapping; - } - attachToComponent(component) { - component.on('render:finished', (component) => { - const urlUtils = new UrlUtils(window.location.href); - const currentUrl = urlUtils.toString(); - Object.entries(this.mapping).forEach(([prop, mapping]) => { - const value = component.valueStore.get(prop); - urlUtils.set(mapping.name, value); - }); - if (currentUrl !== urlUtils.toString()) { - HistoryStrategy.replace(new UrlUtils(currentUrl)); - } - }); - } -} - class SetValueOntoModelFieldsPlugin { attachToComponent(component) { this.synchronizeValueOfModelFields(component); @@ -3084,7 +3065,6 @@ class LiveControllerDefault extends Controller { new PageUnloadingPlugin(), new PollingPlugin(), new SetValueOntoModelFieldsPlugin(), - new QueryStringPlugin(this.queryMappingValue), new ChildComponentPlugin(this.component), ]; plugins.forEach((plugin) => { @@ -3194,7 +3174,6 @@ LiveControllerDefault.values = { debounce: { type: Number, default: 150 }, fingerprint: { type: String, default: '' }, requestMethod: { type: String, default: 'post' }, - queryMapping: { type: Object, default: {} }, }; LiveControllerDefault.backendFactory = (controller) => new Backend(controller.urlValue, controller.requestMethodValue); diff --git a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts index 2792d7fad93..12311b6d64a 100644 --- a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts +++ b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts @@ -26,7 +26,7 @@ export default class { fetchOptions.headers = { Accept: 'application/vnd.live-component+html', 'X-Requested-With': 'XMLHttpRequest', - 'X-Live-Url' : window.location.pathname + window.location.search + 'X-Live-Url': window.location.pathname + window.location.search, }; const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0); diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 825b34b1296..24ff8d8ec2d 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -7,11 +7,11 @@ import ExternalMutationTracker from '../Rendering/ExternalMutationTracker'; import { elementBelongsToThisComponent, getValueFromElement, htmlToElement } from '../dom_utils'; import { executeMorphdom } from '../morphdom'; import { normalizeModelName } from '../string_utils'; +import { HistoryStrategy, UrlUtils } from "../url_utils"; import type { ElementDriver } from './ElementDriver'; import UnsyncedInputsTracker from './UnsyncedInputsTracker'; import ValueStore from './ValueStore'; import type { PluginInterface } from './plugins/PluginInterface'; -import {HistoryStrategy, UrlUtils} from "../url_utils"; declare const Turbo: any; diff --git a/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts deleted file mode 100644 index 26ae489c35d..00000000000 --- a/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { HistoryStrategy, UrlUtils } from '../../url_utils'; -import type Component from '../index'; -import type { PluginInterface } from './PluginInterface'; - -interface QueryMapping { - /** - * URL parameter name - */ - name: string; -} - -export default class implements PluginInterface { - constructor(private readonly mapping: { [p: string]: QueryMapping }) {} - - //@todo delete - attachToComponent(component: Component): void { - component.on('render:finished', (component: Component) => { - const urlUtils = new UrlUtils(window.location.href); - const currentUrl = urlUtils.toString(); - - Object.entries(this.mapping).forEach(([prop, mapping]) => { - const value = component.valueStore.get(prop); - urlUtils.set(mapping.name, value); - }); - - // Only update URL if it has changed - if (currentUrl !== urlUtils.toString()) { - HistoryStrategy.replace(urlUtils); - } - }); - } -} diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index a9ea7f115ee..f0638451704 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -8,7 +8,6 @@ import LoadingPlugin from './Component/plugins/LoadingPlugin'; import PageUnloadingPlugin from './Component/plugins/PageUnloadingPlugin'; import type { PluginInterface } from './Component/plugins/PluginInterface'; import PollingPlugin from './Component/plugins/PollingPlugin'; -import QueryStringPlugin from './Component/plugins/QueryStringPlugin'; import SetValueOntoModelFieldsPlugin from './Component/plugins/SetValueOntoModelFieldsPlugin'; import ValidatedFieldsPlugin from './Component/plugins/ValidatedFieldsPlugin'; import { type DirectiveModifier, parseDirectives } from './Directive/directives_parser'; @@ -42,7 +41,6 @@ export default class LiveControllerDefault extends Controller imple debounce: { type: Number, default: 150 }, fingerprint: { type: String, default: '' }, requestMethod: { type: String, default: 'post' }, - queryMapping: { type: Object, default: {} }, }; declare readonly nameValue: string; @@ -61,7 +59,6 @@ export default class LiveControllerDefault extends Controller imple declare readonly debounceValue: number; declare readonly fingerprintValue: string; declare readonly requestMethodValue: 'get' | 'post'; - declare readonly queryMappingValue: { [p: string]: { name: string } }; /** The component, wrapped in the convenience Proxy */ private proxiedComponent: Component; @@ -301,7 +298,6 @@ export default class LiveControllerDefault extends Controller imple new PageUnloadingPlugin(), new PollingPlugin(), new SetValueOntoModelFieldsPlugin(), - new QueryStringPlugin(this.queryMappingValue), new ChildComponentPlugin(this.component), ]; plugins.forEach((plugin) => { diff --git a/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts b/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts index 44521271e80..6650546f7d3 100644 --- a/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts +++ b/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts @@ -18,6 +18,7 @@ describe('buildRequest', () => { expect(fetchOptions.method).toEqual('GET'); expect(fetchOptions.headers).toEqual({ Accept: 'application/vnd.live-component+html', + 'X-Live-Url': '/', 'X-Requested-With': 'XMLHttpRequest', }); }); @@ -42,6 +43,7 @@ describe('buildRequest', () => { expect(fetchOptions.method).toEqual('POST'); expect(fetchOptions.headers).toEqual({ Accept: 'application/vnd.live-component+html', + 'X-Live-Url': '/', 'X-Requested-With': 'XMLHttpRequest', }); const body = fetchOptions.body; @@ -115,6 +117,7 @@ describe('buildRequest', () => { expect(fetchOptions.headers).toEqual({ // no token Accept: 'application/vnd.live-component+html', + 'X-Live-Url': '/', 'X-Requested-With': 'XMLHttpRequest', }); const body = fetchOptions.body; @@ -145,6 +148,7 @@ describe('buildRequest', () => { expect(fetchOptions.headers).toEqual({ // no token Accept: 'application/vnd.live-component+html', + 'X-Live-Url': '/', 'X-Requested-With': 'XMLHttpRequest', }); const body = fetchOptions.body; @@ -230,6 +234,7 @@ describe('buildRequest', () => { expect(fetchOptions.method).toEqual('POST'); expect(fetchOptions.headers).toEqual({ Accept: 'application/vnd.live-component+html', + 'X-Live-Url': '/', 'X-Requested-With': 'XMLHttpRequest', }); const body = fetchOptions.body; @@ -254,6 +259,7 @@ describe('buildRequest', () => { expect(fetchOptions.method).toEqual('POST'); expect(fetchOptions.headers).toEqual({ Accept: 'application/vnd.live-component+html', + 'X-Live-Url': '/', 'X-Requested-With': 'XMLHttpRequest', }); const body = fetchOptions.body; From f4233de58147286a56d100eead677a6ff639259e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Sun, 6 Apr 2025 10:23:53 +0200 Subject: [PATCH 04/10] UrlMapping.mapPath --- .../src/EventListener/LiveUrlSubscriber.php | 20 ++++++++++++++----- src/LiveComponent/src/Metadata/UrlMapping.php | 7 ++++++- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php index 659f7f6de65..3f2f687572a 100644 --- a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php @@ -67,19 +67,23 @@ private function getLivePropsToMap(Request $request): array $liveData = $request->attributes->get('_live_request_data') ?? []; $values = array_merge($liveData['props'] ?? [], $liveData['updated'] ?? []); - $urlLiveProps = []; + $urlLiveProps = [ + 'path' => [], + 'query' => [], + ]; foreach ($metadata->getAllLivePropsMetadata($component) as $liveProp) { $name = $liveProp->getName(); $urlMapping = $liveProp->urlMapping(); if (isset($values[$name]) && $urlMapping) { - $urlLiveProps[$urlMapping->as ?? $name] = $values[$name]; + $urlLiveProps[$urlMapping->mapPath ? 'path' : 'query'][$urlMapping->as ?? $name] = + $values[$name]; } } return $urlLiveProps; } - private function computeNewUrl(string $previousUrl, array $newProps): string + private function computeNewUrl(string $previousUrl, array $livePropsToMap): string { $parsed = parse_url($previousUrl); @@ -89,10 +93,16 @@ private function computeNewUrl(string $previousUrl, array $newProps): string } parse_str($parsed['query'] ?? '', $previousQueryParams); - return $this->router->generate( + $newUrl = $this->router->generate( $this->router->match($url)['_route'], - array_merge($previousQueryParams, $newProps) + array_merge($previousQueryParams, $livePropsToMap['path']) ); + parse_str(parse_url($newUrl)['query'] ?? '', $queryParams); + $queryString = http_build_query(array_merge($queryParams, $livePropsToMap['query'])); + + return preg_replace('/[?#].*/', '', $newUrl). + ('' !== $queryString ? '?' : ''). + $queryString; } /** diff --git a/src/LiveComponent/src/Metadata/UrlMapping.php b/src/LiveComponent/src/Metadata/UrlMapping.php index be7fd86195e..24150156c9b 100644 --- a/src/LiveComponent/src/Metadata/UrlMapping.php +++ b/src/LiveComponent/src/Metadata/UrlMapping.php @@ -12,7 +12,7 @@ namespace Symfony\UX\LiveComponent\Metadata; /** - * Mapping configuration to bind a LiveProp to a URL query parameter. + * Mapping configuration to bind a LiveProp to a URL path or query parameter. * * @author Nicolas Rigaud */ @@ -23,6 +23,11 @@ public function __construct( * The name of the prop that appears in the URL. If null, the LiveProp's field name is used. */ public readonly ?string $as = null, + + /** + * True if the prop should be mapped to the path if it matches one of its parameters. Otherwise a query parameter will be used. + */ + public readonly bool $mapPath = false, ) { } } From b07adffadeb95882b62327e75293687537b1fd33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Sun, 6 Apr 2025 10:25:02 +0200 Subject: [PATCH 05/10] QueryStringPropsExtractor, renamed to RequestPropsExtractor shall get props also from path. RequestInitializeSubscriber also renamed --- .../DependencyInjection/LiveComponentExtension.php | 13 ++++++++----- ...bscriber.php => RequestInitializeSubscriber.php} | 10 +++++----- ...PropsExtractor.php => RequestPropsExtractor.php} | 11 ++++++----- ...est.php => RequestInitializerSubscriberTest.php} | 2 +- ...ractorTest.php => RequestPropsExtractorTest.php} | 6 +++--- 5 files changed, 23 insertions(+), 19 deletions(-) rename src/LiveComponent/src/EventListener/{QueryStringInitializeSubscriber.php => RequestInitializeSubscriber.php} (83%) rename src/LiveComponent/src/Util/{QueryStringPropsExtractor.php => RequestPropsExtractor.php} (86%) rename src/LiveComponent/tests/Functional/EventListener/{QueryStringInitializerSubscriberTest.php => RequestInitializerSubscriberTest.php} (95%) rename src/LiveComponent/tests/Functional/Util/{QueryStringPropsExtractorTest.php => RequestPropsExtractorTest.php} (93%) diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index 062dda84e8f..6f1c6a63677 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -34,7 +34,7 @@ use Symfony\UX\LiveComponent\EventListener\InterceptChildComponentRenderSubscriber; use Symfony\UX\LiveComponent\EventListener\LiveComponentSubscriber; use Symfony\UX\LiveComponent\EventListener\LiveUrlSubscriber; -use Symfony\UX\LiveComponent\EventListener\QueryStringInitializeSubscriber; +use Symfony\UX\LiveComponent\EventListener\RequestInitializeSubscriber; use Symfony\UX\LiveComponent\EventListener\ResetDeterministicIdSubscriber; use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType; use Symfony\UX\LiveComponent\Hydration\HydrationExtensionInterface; @@ -51,7 +51,7 @@ use Symfony\UX\LiveComponent\Util\FingerprintCalculator; use Symfony\UX\LiveComponent\Util\LiveComponentStack; use Symfony\UX\LiveComponent\Util\LiveControllerAttributesCreator; -use Symfony\UX\LiveComponent\Util\QueryStringPropsExtractor; +use Symfony\UX\LiveComponent\Util\RequestPropsExtractor; use Symfony\UX\LiveComponent\Util\TwigAttributeHelperFactory; use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentRenderer; @@ -138,8 +138,8 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { $container->register('ux.live_component.live_url_subscriber', LiveUrlSubscriber::class) ->setArguments([ - new Reference('router'), new Reference('ux.live_component.metadata_factory'), + new Reference('ux.live_component.url_factory'), ]) ->addTag('kernel.event_subscriber') ; @@ -209,6 +209,9 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { $container->register('ux.live_component.attribute_helper_factory', TwigAttributeHelperFactory::class) ->setArguments([new Reference('twig')]); + $container->register('ux.live_component.url_factory', UrlFactory::class) + ->setArguments([new Reference('router')]); + $container->register('ux.live_component.live_controller_attributes_creator', LiveControllerAttributesCreator::class) ->setArguments([ new Reference('ux.live_component.metadata_factory'), @@ -231,12 +234,12 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { ->addTag('container.service_subscriber', ['key' => LiveControllerAttributesCreator::class, 'id' => 'ux.live_component.live_controller_attributes_creator']) ; - $container->register('ux.live_component.query_string_props_extractor', QueryStringPropsExtractor::class) + $container->register('ux.live_component.query_string_props_extractor', RequestPropsExtractor::class) ->setArguments([ new Reference('ux.live_component.component_hydrator'), ]); - $container->register('ux.live_component.query_string_initializer_subscriber', QueryStringInitializeSubscriber::class) + $container->register('ux.live_component.query_string_initializer_subscriber', RequestInitializeSubscriber::class) ->setArguments([ new Reference('request_stack'), new Reference('ux.live_component.metadata_factory'), diff --git a/src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php b/src/LiveComponent/src/EventListener/RequestInitializeSubscriber.php similarity index 83% rename from src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php rename to src/LiveComponent/src/EventListener/RequestInitializeSubscriber.php index 9dc80577f7a..01893f268c9 100644 --- a/src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php +++ b/src/LiveComponent/src/EventListener/RequestInitializeSubscriber.php @@ -16,7 +16,7 @@ use Symfony\Component\PropertyAccess\Exception\ExceptionInterface as PropertyAccessExceptionInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; -use Symfony\UX\LiveComponent\Util\QueryStringPropsExtractor; +use Symfony\UX\LiveComponent\Util\RequestPropsExtractor; use Symfony\UX\TwigComponent\Event\PostMountEvent; /** @@ -24,12 +24,12 @@ * * @internal */ -class QueryStringInitializeSubscriber implements EventSubscriberInterface +class RequestInitializeSubscriber implements EventSubscriberInterface { public function __construct( private readonly RequestStack $requestStack, private readonly LiveComponentMetadataFactory $metadataFactory, - private readonly QueryStringPropsExtractor $queryStringPropsExtractor, + private readonly RequestPropsExtractor $requestPropsExtractor, private readonly PropertyAccessorInterface $propertyAccessor, ) { } @@ -60,11 +60,11 @@ public function onPostMount(PostMountEvent $event): void return; } - $queryStringData = $this->queryStringPropsExtractor->extract($request, $metadata, $event->getComponent()); + $requestData = $this->requestPropsExtractor->extract($request, $metadata, $event->getComponent()); $component = $event->getComponent(); - foreach ($queryStringData as $name => $value) { + foreach ($requestData as $name => $value) { try { $this->propertyAccessor->setValue($component, $name, $value); } catch (PropertyAccessExceptionInterface $exception) { diff --git a/src/LiveComponent/src/Util/QueryStringPropsExtractor.php b/src/LiveComponent/src/Util/RequestPropsExtractor.php similarity index 86% rename from src/LiveComponent/src/Util/QueryStringPropsExtractor.php rename to src/LiveComponent/src/Util/RequestPropsExtractor.php index 48e852d70ba..865e0032116 100644 --- a/src/LiveComponent/src/Util/QueryStringPropsExtractor.php +++ b/src/LiveComponent/src/Util/RequestPropsExtractor.php @@ -22,20 +22,21 @@ * * @internal */ -final class QueryStringPropsExtractor +final class RequestPropsExtractor { public function __construct(private readonly LiveComponentHydrator $hydrator) { } /** - * Extracts relevant query parameters from the current URL and hydrates them. + * Extracts relevant props parameters from the current URL and hydrates them. */ public function extract(Request $request, LiveComponentMetadata $metadata, object $component): array { - $query = $request->query->all(); + $parameters = array_merge($request->attributes->all(), $request->query->all()); - if (empty($query)) { + // @todo never empty because custom values prefixed with _ ... do something ? + if (empty($parameters)) { return []; } $data = []; @@ -43,7 +44,7 @@ public function extract(Request $request, LiveComponentMetadata $metadata, objec foreach ($metadata->getAllLivePropsMetadata($component) as $livePropMetadata) { if ($queryMapping = $livePropMetadata->urlMapping()) { $frontendName = $livePropMetadata->calculateFieldName($component, $livePropMetadata->getName()); - if (null !== ($value = $query[$queryMapping->as ?? $frontendName] ?? null)) { + if (null !== ($value = $parameters[$queryMapping->as ?? $frontendName] ?? null)) { if ('' === $value && null !== $livePropMetadata->getType() && (!$livePropMetadata->isBuiltIn() || 'array' === $livePropMetadata->getType())) { // Cast empty string to empty array for objects and arrays $value = []; diff --git a/src/LiveComponent/tests/Functional/EventListener/QueryStringInitializerSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/RequestInitializerSubscriberTest.php similarity index 95% rename from src/LiveComponent/tests/Functional/EventListener/QueryStringInitializerSubscriberTest.php rename to src/LiveComponent/tests/Functional/EventListener/RequestInitializerSubscriberTest.php index aa5955c6378..856c264a820 100644 --- a/src/LiveComponent/tests/Functional/EventListener/QueryStringInitializerSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/RequestInitializerSubscriberTest.php @@ -14,7 +14,7 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Zenstruck\Browser\Test\HasBrowser; -class QueryStringInitializerSubscriberTest extends KernelTestCase +class RequestInitializerSubscriberTest extends KernelTestCase { use HasBrowser; diff --git a/src/LiveComponent/tests/Functional/Util/QueryStringPropsExtractorTest.php b/src/LiveComponent/tests/Functional/Util/RequestPropsExtractorTest.php similarity index 93% rename from src/LiveComponent/tests/Functional/Util/QueryStringPropsExtractorTest.php rename to src/LiveComponent/tests/Functional/Util/RequestPropsExtractorTest.php index cabfb98e406..fd29065b531 100644 --- a/src/LiveComponent/tests/Functional/Util/QueryStringPropsExtractorTest.php +++ b/src/LiveComponent/tests/Functional/Util/RequestPropsExtractorTest.php @@ -16,9 +16,9 @@ use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Address; use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper; -use Symfony\UX\LiveComponent\Util\QueryStringPropsExtractor; +use Symfony\UX\LiveComponent\Util\RequestPropsExtractor; -class QueryStringPropsExtractorTest extends KernelTestCase +class RequestPropsExtractorTest extends KernelTestCase { use LiveComponentTestHelper; @@ -27,7 +27,7 @@ class QueryStringPropsExtractorTest extends KernelTestCase */ public function testExtract(string $queryString, array $expected) { - $extractor = new QueryStringPropsExtractor($this->hydrator()); + $extractor = new RequestPropsExtractor($this->hydrator()); $request = Request::create('/'.!empty($queryString) ? '?'.$queryString : ''); From 19666f8511e41ca93fea0a981640cff091cf8ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Thu, 10 Apr 2025 20:18:37 +0200 Subject: [PATCH 06/10] UrlFactory --- .../LiveComponentExtension.php | 1 + .../src/EventListener/LiveUrlSubscriber.php | 71 +++++----------- src/LiveComponent/src/Util/UrlFactory.php | 83 +++++++++++++++++++ 3 files changed, 103 insertions(+), 52 deletions(-) create mode 100644 src/LiveComponent/src/Util/UrlFactory.php diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index 6f1c6a63677..2d12f13a02a 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -53,6 +53,7 @@ use Symfony\UX\LiveComponent\Util\LiveControllerAttributesCreator; use Symfony\UX\LiveComponent\Util\RequestPropsExtractor; use Symfony\UX\LiveComponent\Util\TwigAttributeHelperFactory; +use Symfony\UX\LiveComponent\Util\UrlFactory; use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentRenderer; diff --git a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php index 3f2f687572a..b2f9ec030af 100644 --- a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php @@ -15,40 +15,47 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; -use Symfony\Component\Routing\RouterInterface; use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; +use Symfony\UX\LiveComponent\Util\UrlFactory; class LiveUrlSubscriber implements EventSubscriberInterface { private const URL_HEADER = 'X-Live-Url'; public function __construct( - private readonly RouterInterface $router, - private readonly LiveComponentMetadataFactory $metadataFactory, + private LiveComponentMetadataFactory $metadataFactory, + private UrlFactory $urlFactory, ) { } public function onKernelResponse(ResponseEvent $event): void { - if (!$this->isLiveComponentRequest($request = $event->getRequest())) { + $request = $event->getRequest(); + if (!$request->attributes->has('_live_component')) { return; } if (!$event->isMainRequest()) { return; } + $newUrl = null; if ($previousLocation = $request->headers->get(self::URL_HEADER)) { - $newUrl = $this->computeNewUrl( - $previousLocation, - $this->getLivePropsToMap($request) - ); - if ($newUrl) { - $event->getResponse()->headers->set( - self::URL_HEADER, - $newUrl + $liveProps = $this->getLivePropsToMap($request); + if (!empty($liveProps)) { + $newUrl = $this->urlFactory->createFromPreviousAndProps( + $previousLocation, + $liveProps['path'], + $liveProps['query'] ); } } + + if ($newUrl) { + $event->getResponse()->headers->set( + self::URL_HEADER, + $newUrl + ); + } } public static function getSubscribedEvents(): array @@ -82,44 +89,4 @@ private function getLivePropsToMap(Request $request): array return $urlLiveProps; } - - private function computeNewUrl(string $previousUrl, array $livePropsToMap): string - { - $parsed = parse_url($previousUrl); - - $url = $parsed['path'] ?? ''; - if (isset($parsed['query'])) { - $url .= '?'.$parsed['query']; - } - parse_str($parsed['query'] ?? '', $previousQueryParams); - - $newUrl = $this->router->generate( - $this->router->match($url)['_route'], - array_merge($previousQueryParams, $livePropsToMap['path']) - ); - parse_str(parse_url($newUrl)['query'] ?? '', $queryParams); - $queryString = http_build_query(array_merge($queryParams, $livePropsToMap['query'])); - - return preg_replace('/[?#].*/', '', $newUrl). - ('' !== $queryString ? '?' : ''). - $queryString; - } - - /** - * copied from LiveComponentSubscriber. - */ - private function isLiveComponentRequest(Request $request): bool - { - if (!$request->attributes->has('_live_component')) { - return false; - } - - // if ($this->testMode) { - // return true; - // } - - // Except when testing, require the correct content-type in the Accept header. - // This also acts as a CSRF protection since this can only be set in accordance with same-origin/CORS policies. - return \in_array('application/vnd.live-component+html', $request->getAcceptableContentTypes(), true); - } } diff --git a/src/LiveComponent/src/Util/UrlFactory.php b/src/LiveComponent/src/Util/UrlFactory.php new file mode 100644 index 00000000000..e85029aa472 --- /dev/null +++ b/src/LiveComponent/src/Util/UrlFactory.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Util; + +use Symfony\Component\Routing\RouterInterface; + +/** + * @internal + */ +class UrlFactory +{ + public function __construct( + private RouterInterface $router, + ) { + } + + public function createFromPreviousAndProps( + string $previousUrl, + array $pathMappedProps, + array $queryMappedProps, + ): string { + $parsed = parse_url($previousUrl); + + // Make sure to handle only path and query + $previousUrl = $parsed['path'] ?? ''; + if (isset($parsed['query'])) { + $previousUrl .= '?'.$parsed['query']; + } + + $newUrl = $this->createPath($previousUrl, $pathMappedProps); + + return $this->replaceQueryString( + $newUrl, + array_merge( + $this->getPreviousQueryParameters($parsed['query'] ?? ''), + $this->getRemnantProps($newUrl), + $queryMappedProps, + ) + ); + } + + private function createPath(string $previousUrl, array $props): string + { + return $this->router->generate( + $this->router->match($previousUrl)['_route'], + $props + ); + } + + private function replaceQueryString($url, array $props): string + { + $queryString = http_build_query($props); + + return preg_replace('/[?#].*/', '', $url). + ('' !== $queryString ? '?' : ''). + $queryString; + } + + // Keep the query parameters of the previous request + private function getPreviousQueryParameters(string $query): array + { + parse_str($query, $previousQueryParams); + + return $previousQueryParams; + } + + // Symfony router will set props in query if they do not match route parameter + private function getRemnantProps(string $newUrl): array + { + parse_str(parse_url($newUrl)['query'] ?? '', $remnantQueryParams); + + return $remnantQueryParams; + } +} From 4a1ec50456ffbd0d7779a408fde950314ee54bfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Thu, 10 Apr 2025 20:19:32 +0200 Subject: [PATCH 07/10] remove UrlUtils --- .../assets/dist/live_controller.js | 106 +---------- src/LiveComponent/assets/dist/url_utils.d.ts | 11 -- .../assets/src/Component/index.ts | 7 +- src/LiveComponent/assets/src/url_utils.ts | 172 ------------------ .../assets/test/url_utils.test.ts | 134 -------------- 5 files changed, 6 insertions(+), 424 deletions(-) delete mode 100644 src/LiveComponent/assets/dist/url_utils.d.ts delete mode 100644 src/LiveComponent/assets/src/url_utils.ts delete mode 100644 src/LiveComponent/assets/test/url_utils.test.ts diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 49497a9a0de..afb6899c40b 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1797,110 +1797,6 @@ function executeMorphdom(rootFromElement, rootToElement, modifiedFieldElements, }); } -function isValueEmpty(value) { - if (null === value || value === '' || undefined === value || (Array.isArray(value) && value.length === 0)) { - return true; - } - if (typeof value !== 'object') { - return false; - } - for (const key of Object.keys(value)) { - if (!isValueEmpty(value[key])) { - return false; - } - } - return true; -} -function toQueryString(data) { - const buildQueryStringEntries = (data, entries = {}, baseKey = '') => { - Object.entries(data).forEach(([iKey, iValue]) => { - const key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`; - if ('' === baseKey && isValueEmpty(iValue)) { - entries[key] = ''; - } - else if (null !== iValue) { - if (typeof iValue === 'object') { - entries = { ...entries, ...buildQueryStringEntries(iValue, entries, key) }; - } - else { - entries[key] = encodeURIComponent(iValue) - .replace(/%20/g, '+') - .replace(/%2C/g, ','); - } - } - }); - return entries; - }; - const entries = buildQueryStringEntries(data); - return Object.entries(entries) - .map(([key, value]) => `${key}=${value}`) - .join('&'); -} -function fromQueryString(search) { - search = search.replace('?', ''); - if (search === '') - return {}; - const insertDotNotatedValueIntoData = (key, value, data) => { - const [first, second, ...rest] = key.split('.'); - if (!second) { - data[key] = value; - return value; - } - if (data[first] === undefined) { - data[first] = Number.isNaN(Number.parseInt(second)) ? {} : []; - } - insertDotNotatedValueIntoData([second, ...rest].join('.'), value, data[first]); - }; - const entries = search.split('&').map((i) => i.split('=')); - const data = {}; - entries.forEach(([key, value]) => { - value = decodeURIComponent(value.replace(/\+/g, '%20')); - if (!key.includes('[')) { - data[key] = value; - } - else { - if ('' === value) - return; - const dotNotatedKey = key.replace(/\[/g, '.').replace(/]/g, ''); - insertDotNotatedValueIntoData(dotNotatedKey, value, data); - } - }); - return data; -} -class UrlUtils extends URL { - has(key) { - const data = this.getData(); - return Object.keys(data).includes(key); - } - set(key, value) { - const data = this.getData(); - data[key] = value; - this.setData(data); - } - get(key) { - return this.getData()[key]; - } - remove(key) { - const data = this.getData(); - delete data[key]; - this.setData(data); - } - getData() { - if (!this.search) { - return {}; - } - return fromQueryString(this.search); - } - setData(data) { - this.search = toQueryString(data); - } -} -class HistoryStrategy { - static replace(url) { - history.replaceState(history.state, '', url); - } -} - class UnsyncedInputsTracker { constructor(component, modelElementResolver) { this.elementEventListeners = [ @@ -2250,7 +2146,7 @@ class Component { this.processRerender(html, backendResponse); const liveUrl = await backendResponse.getLiveUrl(); if (liveUrl) { - HistoryStrategy.replace(new UrlUtils(liveUrl + window.location.hash, window.location.origin)); + history.replaceState(history.state, '', new URL(liveUrl + window.location.hash, window.location.origin)); } this.backendRequest = null; thisPromiseResolve(backendResponse); diff --git a/src/LiveComponent/assets/dist/url_utils.d.ts b/src/LiveComponent/assets/dist/url_utils.d.ts deleted file mode 100644 index c54c70f08ac..00000000000 --- a/src/LiveComponent/assets/dist/url_utils.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -export declare class UrlUtils extends URL { - has(key: string): boolean; - set(key: string, value: any): void; - get(key: string): any | undefined; - remove(key: string): void; - private getData; - private setData; -} -export declare class HistoryStrategy { - static replace(url: URL): void; -} diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 24ff8d8ec2d..79c46c74ae9 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -7,7 +7,6 @@ import ExternalMutationTracker from '../Rendering/ExternalMutationTracker'; import { elementBelongsToThisComponent, getValueFromElement, htmlToElement } from '../dom_utils'; import { executeMorphdom } from '../morphdom'; import { normalizeModelName } from '../string_utils'; -import { HistoryStrategy, UrlUtils } from "../url_utils"; import type { ElementDriver } from './ElementDriver'; import UnsyncedInputsTracker from './UnsyncedInputsTracker'; import ValueStore from './ValueStore'; @@ -331,7 +330,11 @@ export default class Component { this.processRerender(html, backendResponse); const liveUrl = await backendResponse.getLiveUrl(); if (liveUrl) { - HistoryStrategy.replace(new UrlUtils(liveUrl + window.location.hash, window.location.origin)); + history.replaceState( + history.state, + '', + new URL(liveUrl + window.location.hash, window.location.origin) + ); } // finally resolve this promise diff --git a/src/LiveComponent/assets/src/url_utils.ts b/src/LiveComponent/assets/src/url_utils.ts deleted file mode 100644 index 93fedacfe7e..00000000000 --- a/src/LiveComponent/assets/src/url_utils.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Adapted from Livewire's history plugin. - * - * @see https://github.com/livewire/livewire/blob/d4839e3b2c23fc71e615e68bc29ff4de95751810/js/plugins/history/index.js - */ - -/** - * Check if a value is empty. - * - * Empty values are: - * - `null` and `undefined` - * - Empty strings - * - Empty arrays - * - Deeply empty objects - */ -function isValueEmpty(value: any): boolean { - if (null === value || value === '' || undefined === value || (Array.isArray(value) && value.length === 0)) { - return true; - } - - if (typeof value !== 'object') { - return false; - } - - for (const key of Object.keys(value)) { - if (!isValueEmpty(value[key])) { - return false; - } - } - - return true; -} - -/** - * Converts JavaScript data to bracketed query string notation. - * - * Input: `{ items: [['foo']] }` - * - * Output: `"items[0][0]=foo"` - */ -function toQueryString(data: any) { - const buildQueryStringEntries = (data: { [p: string]: any }, entries: any = {}, baseKey = '') => { - Object.entries(data).forEach(([iKey, iValue]) => { - const key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`; - - if ('' === baseKey && isValueEmpty(iValue)) { - // Top level empty parameter - entries[key] = ''; - } else if (null !== iValue) { - if (typeof iValue === 'object') { - // Non-empty object/array process - entries = { ...entries, ...buildQueryStringEntries(iValue, entries, key) }; - } else { - // Scalar value - entries[key] = encodeURIComponent(iValue) - .replace(/%20/g, '+') // Conform to RFC1738 - .replace(/%2C/g, ','); - } - } - }); - - return entries; - }; - - const entries = buildQueryStringEntries(data); - - return Object.entries(entries) - .map(([key, value]) => `${key}=${value}`) - .join('&'); -} - -/** - * Converts bracketed query string notation to JavaScript data. - * - * Input: `"items[0][0]=foo"` - * - * Output: `{ items: [['foo']] }` - */ -function fromQueryString(search: string) { - search = search.replace('?', ''); - - if (search === '') return {}; - - const insertDotNotatedValueIntoData = (key: string, value: any, data: any) => { - const [first, second, ...rest] = key.split('.'); - - // We're at a leaf node, let's make the assigment... - if (!second) { - data[key] = value; - return value; - } - - // This is where we fill in empty arrays/objects along the way to the assigment... - if (data[first] === undefined) { - data[first] = Number.isNaN(Number.parseInt(second)) ? {} : []; - } - - // Keep deferring assignment until the full key is built up... - insertDotNotatedValueIntoData([second, ...rest].join('.'), value, data[first]); - }; - - const entries = search.split('&').map((i) => i.split('=')); - - const data: any = {}; - - entries.forEach(([key, value]) => { - value = decodeURIComponent(value.replace(/\+/g, '%20')); - - if (!key.includes('[')) { - data[key] = value; - } else { - // Skip empty nested data - if ('' === value) return; - - // Convert to dot notation because it's easier... - const dotNotatedKey = key.replace(/\[/g, '.').replace(/]/g, ''); - - insertDotNotatedValueIntoData(dotNotatedKey, value, data); - } - }); - - return data; -} - -/** - * Wraps a URL to manage search parameters with common map functions. - */ -export class UrlUtils extends URL { - has(key: string) { - const data = this.getData(); - - return Object.keys(data).includes(key); - } - - set(key: string, value: any) { - const data = this.getData(); - - data[key] = value; - - this.setData(data); - } - - get(key: string): any | undefined { - return this.getData()[key]; - } - - remove(key: string) { - const data = this.getData(); - - delete data[key]; - - this.setData(data); - } - - private getData() { - if (!this.search) { - return {}; - } - - return fromQueryString(this.search); - } - - private setData(data: any) { - this.search = toQueryString(data); - } -} - -export class HistoryStrategy { - static replace(url: URL) { - history.replaceState(history.state, '', url); - } -} diff --git a/src/LiveComponent/assets/test/url_utils.test.ts b/src/LiveComponent/assets/test/url_utils.test.ts deleted file mode 100644 index fcb711f59cc..00000000000 --- a/src/LiveComponent/assets/test/url_utils.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/* - * This file is part of the Symfony package. - * - * (c) Fabien Potencier - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { HistoryStrategy, UrlUtils } from '../src/url_utils'; - -describe('url_utils', () => { - describe('UrlUtils', () => { - describe('set', () => { - const urlUtils: UrlUtils = new UrlUtils(window.location.href); - - beforeEach(() => { - // Reset search before each test - urlUtils.search = ''; - }); - - it('set the param if it does not exist', () => { - urlUtils.set('param', 'foo'); - - expect(urlUtils.search).toEqual('?param=foo'); - }); - - it('override the param if it exists', () => { - urlUtils.search = '?param=foo'; - - urlUtils.set('param', 'bar'); - - expect(urlUtils.search).toEqual('?param=bar'); - }); - - it('preserve empty values if the param is scalar', () => { - urlUtils.set('param', ''); - - expect(urlUtils.search).toEqual('?param='); - }); - - it('expand arrays in the URL', () => { - urlUtils.set('param', ['foo', 'bar']); - - expect(urlUtils.search).toEqual('?param[0]=foo¶m[1]=bar'); - }); - - it('keep empty values if the param is an empty array', () => { - urlUtils.set('param', []); - - expect(urlUtils.search).toEqual('?param='); - }); - - it('expand objects in the URL', () => { - urlUtils.set('param', { - foo: 1, - bar: 'baz', - }); - - expect(urlUtils.search).toEqual('?param[foo]=1¶m[bar]=baz'); - }); - - it('remove empty values in nested object properties', () => { - urlUtils.set('param', { - foo: null, - bar: 'baz', - }); - - expect(urlUtils.search).toEqual('?param[bar]=baz'); - }); - - it('keep empty values if the param is an empty object', () => { - urlUtils.set('param', {}); - - expect(urlUtils.search).toEqual('?param='); - }); - }); - - describe('remove', () => { - const urlUtils: UrlUtils = new UrlUtils(window.location.href); - - beforeEach(() => { - // Reset search before each test - urlUtils.search = ''; - }); - it('remove the param if it exists', () => { - urlUtils.search = '?param=foo'; - - urlUtils.remove('param'); - - expect(urlUtils.search).toEqual(''); - }); - - it('keep other params unchanged', () => { - urlUtils.search = '?param=foo&otherParam=bar'; - - urlUtils.remove('param'); - - expect(urlUtils.search).toEqual('?otherParam=bar'); - }); - - it('remove all occurrences of an array param', () => { - urlUtils.search = '?param[0]=foo¶m[1]=bar'; - - urlUtils.remove('param'); - - expect(urlUtils.search).toEqual(''); - }); - - it('remove all occurrences of an object param', () => { - urlUtils.search = '?param[foo]=1¶m[bar]=baz'; - - urlUtils.remove('param'); - - expect(urlUtils.search).toEqual(''); - }); - }); - }); - - describe('HistoryStrategy', () => { - let initialUrl: URL; - beforeAll(() => { - initialUrl = new URL(window.location.href); - }); - afterEach(() => { - history.replaceState(history.state, '', initialUrl); - }); - it('replace URL', () => { - const newUrl = new URL(`${window.location.href}/foo/bar`); - HistoryStrategy.replace(newUrl); - expect(window.location.href).toEqual(newUrl.toString()); - }); - }); -}); From 80945b5b7b2c2ba29c6e96c08bbd673ef9f5a37b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Mon, 12 May 2025 17:54:35 +0200 Subject: [PATCH 08/10] add server-side changed props in request to be handled by LiveUrlSubscriber --- .../src/EventListener/LiveComponentSubscriber.php | 12 +++++++++++- .../src/EventListener/LiveUrlSubscriber.php | 6 +++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 58b5df6d111..5e6597ae583 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -255,7 +255,17 @@ public function onKernelView(ViewEvent $event): void return; } - $event->setResponse($this->createResponse($request->attributes->get('_mounted_component'))); + $mountedComponent = $request->attributes->get('_mounted_component'); + if (!$request->attributes->get('_component_default_action', false)) { + // On custom action, props may be updated by the server side + // @todo discuss name responseProps + // @todo maybe always set in, including default action and use only this, ignoring `props` and `updated` for UrlFactory ? + $liveRequestData = $request->attributes->get('_live_request_data'); + $liveRequestData['responseProps'] = (array) $mountedComponent->getComponent(); + $request->attributes->set('_live_request_data', $liveRequestData); + } + + $event->setResponse($this->createResponse($mountedComponent)); } public function onKernelException(ExceptionEvent $event): void diff --git a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php index b2f9ec030af..085ce89bb49 100644 --- a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php @@ -72,7 +72,11 @@ private function getLivePropsToMap(Request $request): array $metadata = $this->metadataFactory->getMetadata($componentName); $liveData = $request->attributes->get('_live_request_data') ?? []; - $values = array_merge($liveData['props'] ?? [], $liveData['updated'] ?? []); + $values = array_merge( + $liveData['props'] ?? [], + $liveData['updated'] ?? [], + $liveData['responseProps'] ?? [] + ); $urlLiveProps = [ 'path' => [], From 04dc91adba02dd4ca6469bd86f8d275f0be1cfe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Fri, 9 May 2025 17:13:15 +0200 Subject: [PATCH 09/10] fix query-binding tests --- .../test/controller/query-binding.test.ts | 46 ++++++++++++------- src/LiveComponent/assets/test/tools.ts | 15 +++++- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/src/LiveComponent/assets/test/controller/query-binding.test.ts b/src/LiveComponent/assets/test/controller/query-binding.test.ts index f0654efe8e9..6aea82fa6ca 100644 --- a/src/LiveComponent/assets/test/controller/query-binding.test.ts +++ b/src/LiveComponent/assets/test/controller/query-binding.test.ts @@ -49,14 +49,14 @@ describe('LiveController query string binding', () => { // String // Set value - test.expectsAjaxCall().expectUpdatedData({ prop1: 'foo' }); + test.expectsAjaxCall().expectUpdatedData({ prop1: 'foo' }).willReturnLiveUrl('?prop1=foo&prop2='); await test.component.set('prop1', 'foo', true); expectCurrentSearch().toEqual('?prop1=foo&prop2='); // Remove value - test.expectsAjaxCall().expectUpdatedData({ prop1: '' }); + test.expectsAjaxCall().expectUpdatedData({ prop1: '' }).willReturnLiveUrl('?prop1=&prop2='); await test.component.set('prop1', '', true); @@ -65,14 +65,14 @@ describe('LiveController query string binding', () => { // Number // Set value - test.expectsAjaxCall().expectUpdatedData({ prop2: 42 }); + test.expectsAjaxCall().expectUpdatedData({ prop2: 42 }).willReturnLiveUrl('?prop1=&prop2=42'); await test.component.set('prop2', 42, true); expectCurrentSearch().toEqual('?prop1=&prop2=42'); // Remove value - test.expectsAjaxCall().expectUpdatedData({ prop2: null }); + test.expectsAjaxCall().expectUpdatedData({ prop2: null }).willReturnLiveUrl('?prop1=&prop2='); await test.component.set('prop2', null, true); @@ -88,21 +88,25 @@ describe('LiveController query string binding', () => { ); // Set value - test.expectsAjaxCall().expectUpdatedData({ prop: ['foo', 'bar'] }); + test.expectsAjaxCall() + .expectUpdatedData({ prop: ['foo', 'bar'] }) + .willReturnLiveUrl('?prop[0]=foo&prop[1]=bar'); await test.component.set('prop', ['foo', 'bar'], true); expectCurrentSearch().toEqual('?prop[0]=foo&prop[1]=bar'); // Remove one value - test.expectsAjaxCall().expectUpdatedData({ prop: ['foo'] }); + test.expectsAjaxCall() + .expectUpdatedData({ prop: ['foo'] }) + .willReturnLiveUrl('?prop[0]=foo'); await test.component.set('prop', ['foo'], true); expectCurrentSearch().toEqual('?prop[0]=foo'); // Remove all remaining values - test.expectsAjaxCall().expectUpdatedData({ prop: [] }); + test.expectsAjaxCall().expectUpdatedData({ prop: [] }).willReturnLiveUrl('?prop='); await test.component.set('prop', [], true); @@ -118,28 +122,34 @@ describe('LiveController query string binding', () => { ); // Set single nested prop - test.expectsAjaxCall().expectUpdatedData({ 'prop.foo': 'dummy' }); + test.expectsAjaxCall().expectUpdatedData({ 'prop.foo': 'dummy' }).willReturnLiveUrl('?prop[foo]=dummy'); await test.component.set('prop.foo', 'dummy', true); expectCurrentSearch().toEqual('?prop[foo]=dummy'); // Set multiple values - test.expectsAjaxCall().expectUpdatedData({ prop: { foo: 'other', bar: 42 } }); + test.expectsAjaxCall() + .expectUpdatedData({ prop: { foo: 'other', bar: 42 } }) + .willReturnLiveUrl('?prop[foo]=other&prop[bar]=42'); await test.component.set('prop', { foo: 'other', bar: 42 }, true); expectCurrentSearch().toEqual('?prop[foo]=other&prop[bar]=42'); // Remove one value - test.expectsAjaxCall().expectUpdatedData({ prop: { foo: 'other', bar: null } }); + test.expectsAjaxCall() + .expectUpdatedData({ prop: { foo: 'other', bar: null } }) + .willReturnLiveUrl('?prop[foo]=other'); await test.component.set('prop', { foo: 'other', bar: null }, true); expectCurrentSearch().toEqual('?prop[foo]=other'); // Remove all values - test.expectsAjaxCall().expectUpdatedData({ prop: { foo: null, bar: null } }); + test.expectsAjaxCall() + .expectUpdatedData({ prop: { foo: null, bar: null } }) + .willReturnLiveUrl('?prop='); await test.component.set('prop', { foo: null, bar: null }, true); @@ -161,13 +171,15 @@ describe('LiveController query string binding', () => { .expectActionCalled('changeProp') .serverWillChangeProps((data: any) => { data.prop = 'foo'; - }); + }) + .willReturnLiveUrl('?prop=foo'); getByText(test.element, 'Change prop').click(); - await waitFor(() => expect(test.element).toHaveTextContent('Prop: foo')); - - expectCurrentSearch().toEqual('?prop=foo'); + await waitFor(() => { + expect(test.element).toHaveTextContent('Prop: foo'); + expectCurrentSearch().toEqual('?prop=foo'); + }); }); it('uses custom name instead of prop name in the URL', async () => { @@ -179,14 +191,14 @@ describe('LiveController query string binding', () => { ); // Set value - test.expectsAjaxCall().expectUpdatedData({ prop1: 'foo' }); + test.expectsAjaxCall().expectUpdatedData({ prop1: 'foo' }).willReturnLiveUrl('?alias1=foo'); await test.component.set('prop1', 'foo', true); expectCurrentSearch().toEqual('?alias1=foo'); // Remove value - test.expectsAjaxCall().expectUpdatedData({ prop1: '' }); + test.expectsAjaxCall().expectUpdatedData({ prop1: '' }).willReturnLiveUrl('?alias1='); await test.component.set('prop1', '', true); diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts index 41a6b11a29c..228d8254fbf 100644 --- a/src/LiveComponent/assets/test/tools.ts +++ b/src/LiveComponent/assets/test/tools.ts @@ -173,6 +173,7 @@ class MockedAjaxCall { /* Response properties */ private changePropsCallback?: (props: any) => void; private template?: (props: any) => string; + private liveUrl?: string; private delayResponseTime?: number = 0; private customResponseStatusCode?: number; private customResponseHTML?: string; @@ -269,10 +270,16 @@ class MockedAjaxCall { const html = this.customResponseHTML ? this.customResponseHTML : template(newProps); // assume a normal, live-component response unless it's totally custom - const headers = { 'Content-Type': 'application/vnd.live-component+html' }; + const headers = { + 'Content-Type': 'application/vnd.live-component+html', + 'X-Live-Url': '', + }; if (this.customResponseHTML) { headers['Content-Type'] = 'text/html'; } + if (this.liveUrl) { + headers['X-Live-Url'] = this.liveUrl; + } const response = new Response(html, { status: this.customResponseStatusCode || 200, @@ -342,6 +349,12 @@ class MockedAjaxCall { return this; } + willReturnLiveUrl(liveUrl: string): MockedAjaxCall { + this.liveUrl = liveUrl; + + return this; + } + serverWillReturnCustomResponse(statusCode: number, responseHTML: string): MockedAjaxCall { this.customResponseStatusCode = statusCode; this.customResponseHTML = responseHTML; From 043ebdcbb34b295f6e23596a5d2c0b1c8e1a62ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Fri, 16 May 2025 23:17:20 +0200 Subject: [PATCH 10/10] UrlFactoryTest --- src/LiveComponent/src/Util/UrlFactory.php | 7 +- .../tests/Unit/Util/UrlFactoryTest.php | 139 ++++++++++++++++++ 2 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 src/LiveComponent/tests/Unit/Util/UrlFactoryTest.php diff --git a/src/LiveComponent/src/Util/UrlFactory.php b/src/LiveComponent/src/Util/UrlFactory.php index e85029aa472..cda618698f0 100644 --- a/src/LiveComponent/src/Util/UrlFactory.php +++ b/src/LiveComponent/src/Util/UrlFactory.php @@ -27,8 +27,11 @@ public function createFromPreviousAndProps( string $previousUrl, array $pathMappedProps, array $queryMappedProps, - ): string { + ): ?string { $parsed = parse_url($previousUrl); + if (false === $parsed) { + return null; + } // Make sure to handle only path and query $previousUrl = $parsed['path'] ?? ''; @@ -51,7 +54,7 @@ public function createFromPreviousAndProps( private function createPath(string $previousUrl, array $props): string { return $this->router->generate( - $this->router->match($previousUrl)['_route'], + $this->router->match($previousUrl)['_route'] ?? '', $props ); } diff --git a/src/LiveComponent/tests/Unit/Util/UrlFactoryTest.php b/src/LiveComponent/tests/Unit/Util/UrlFactoryTest.php new file mode 100644 index 00000000000..89a2f304f5d --- /dev/null +++ b/src/LiveComponent/tests/Unit/Util/UrlFactoryTest.php @@ -0,0 +1,139 @@ + []; + + yield 'keep_relative_url' => [ + 'input' => ['previousUrl' => '/foo/bar'], + 'expectedUrl' => '/foo/bar', + ]; + + yield 'keep_absolute_url' => [ + 'input' => ['previousUrl' => 'https://symfony.com/foo/bar'], + 'expectedUrl' => '/foo/bar', + 'routerStubData' => [ + 'previousUrl' => '/foo/bar', + 'newUrl' => '/foo/bar', + ], + ]; + + yield 'keep_url_with_query_parameters' => [ + 'input' => ['previousUrl' => 'https://symfony.com/foo/bar?prop1=val1&prop2=val2'], + '/foo/bar?prop1=val1&prop2=val2', + 'routerStubData' => [ + 'previousUrl' => '/foo/bar?prop1=val1&prop2=val2', + 'newUrl' => '/foo/bar?prop1=val1&prop2=val2', + ], + ]; + + yield 'add_query_parameters' => [ + 'input' => [ + 'previousUrl' => '/foo/bar', + 'queryMappedProps' => ['prop1' => 'val1', 'prop2' => 'val2'], + ], + 'expectedUrl' => '/foo/bar?prop1=val1&prop2=val2', + ]; + + yield 'override_previous_matching_query_parameters' => [ + 'input' => [ + 'previousUrl' => '/foo/bar?prop1=oldValue&prop3=oldValue', + 'queryMappedProps' => ['prop1' => 'val1', 'prop2' => 'val2'], + ], + 'expectedUrl' => '/foo/bar?prop1=val1&prop3=oldValue&prop2=val2', + ]; + + yield 'add_path_parameters' => [ + 'input' => [ + 'previousUrl' => '/foo/bar', + 'pathMappedProps' => ['value' => 'baz'], + ], + 'expectedUrl' => '/foo/baz', + 'routerStubData' => [ + 'previousUrl' => '/foo/bar', + 'newUrl' => '/foo/baz', + 'props' => ['value' => 'baz'], + ], + ]; + + yield 'add_both_parameters' => [ + 'input' => [ + 'previousUrl' => '/foo/bar', + 'pathMappedProps' => ['value' => 'baz'], + 'queryMappedProps' => ['filter' => 'all'], + ], + 'expectedUrl' => '/foo/baz?filter=all', + 'routerStubData' => [ + 'previousUrl' => '/foo/bar', + 'newUrl' => '/foo/baz', + 'props' => ['value' => 'baz'], + ], + ]; + + yield 'handle_path_parameter_not_recognized' => [ + 'input' => [ + 'previousUrl' => '/foo/bar', + 'pathMappedProps' => ['value' => 'baz'], + ], + 'expectedUrl' => '/foo/bar?value=baz', + 'routerStubData' => [ + 'previousUrl' => '/foo/bar', + 'newUrl' => '/foo/bar?value=baz', + 'props' => ['value' => 'baz'], + ], + ]; + } + + /** + * @dataProvider getData + */ + public function testCreate( + array $input = [], + string $expectedUrl = '', + array $routerStubData = [], + ): void { + $previousUrl = $input['previousUrl'] ?? ''; + $router = $this->createRouterStub( + $routerStubData['previousUrl'] ?? $previousUrl, + $routerStubData['newUrl'] ?? $previousUrl, + $routerStubData['props'] ?? [], + ); + $factory = new UrlFactory($router); + $newUrl = $factory->createFromPreviousAndProps( + $previousUrl, + $input['pathMappedProps'] ?? [], + $input['queryMappedProps'] ?? [] + ); + + $this->assertEquals($expectedUrl, $newUrl); + } + + private function createRouterStub( + string $previousUrl, + string $newUrl, + array $props = [], + ): RouterInterface { + $matchedRoute = 'default'; + $router = $this->createMock(RouterInterface::class); + $router->expects(self::once()) + ->method('match') + ->with($previousUrl) + ->willReturn(['_route' => $matchedRoute]); + $router->expects(self::once()) + ->method('generate') + ->with($matchedRoute, $props) + ->willReturn($newUrl); + + return $router; + } +}