From 36fb1f065dc056b0f3720d04dbda927e5b18a77f Mon Sep 17 00:00:00 2001 From: Nicolas Rigaud Date: Fri, 3 Nov 2023 14:29:21 +0100 Subject: [PATCH] Rework URL utils --- .../Component/plugins/QueryStringPlugin.d.ts | 16 +- .../assets/dist/live_controller.d.ts | 6 +- .../assets/dist/live_controller.js | 191 ++++++++++-------- src/LiveComponent/assets/dist/url_utils.d.ts | 13 +- .../Component/plugins/QueryStringPlugin.ts | 117 ++++------- .../assets/src/live_controller.ts | 2 +- src/LiveComponent/assets/src/url_utils.ts | 158 +++++++++++---- .../test/controller/query-binding.test.ts | 4 +- .../assets/test/url_utils.test.ts | 120 ++++++----- .../src/Util/QueryStringPropsExtractor.php | 12 +- 10 files changed, 365 insertions(+), 274 deletions(-) diff --git a/src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts b/src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts index b859032e0f1..90b44468ea3 100644 --- a/src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts +++ b/src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts @@ -1,16 +1,14 @@ import Component from '../index'; import { PluginInterface } from './PluginInterface'; +interface QueryMapping { + name: string; +} export default class implements PluginInterface { - private mapping; - private initialPropsValues; - private changedProps; + private readonly mapping; + private trackers; constructor(mapping: { - [p: string]: any; + [p: string]: QueryMapping; }); attachToComponent(component: Component): void; - private updateUrlParam; - private getParamFromModel; - private getNormalizedPropNames; - private isValueEmpty; - private isObjectValue; } +export {}; diff --git a/src/LiveComponent/assets/dist/live_controller.d.ts b/src/LiveComponent/assets/dist/live_controller.d.ts index 8a924142344..535cc5f39e9 100644 --- a/src/LiveComponent/assets/dist/live_controller.d.ts +++ b/src/LiveComponent/assets/dist/live_controller.d.ts @@ -48,7 +48,11 @@ export default class LiveControllerDefault extends Controller imple readonly hasDebounceValue: boolean; readonly debounceValue: number; readonly fingerprintValue: string; - readonly queryMappingValue: Map; + readonly queryMappingValue: { + [p: string]: { + name: string; + }; + }; private proxiedComponent; component: Component; pendingActionTriggerModelElement: HTMLElement | null; diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 61d54f93583..5bff40a8c4f 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -2696,118 +2696,131 @@ class ComponentRegistry { } } -class AdvancedURLSearchParams extends URLSearchParams { - set(name, value) { - if (typeof value !== 'object') { - super.set(name, value); - } - else { - this.delete(name); - if (Array.isArray(value)) { - value.forEach((v) => { - this.append(`${name}[]`, v); - }); +function isObject(subject) { + return typeof subject === 'object' && subject !== null; +} +function toQueryString(data) { + const buildQueryStringEntries = (data, entries = {}, baseKey = '') => { + Object.entries(data).forEach(([iKey, iValue]) => { + const key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`; + if (!isObject(iValue)) { + if (iValue !== null) { + entries[key] = encodeURIComponent(iValue) + .replace(/%20/g, '+') + .replace(/%2C/g, ','); + } } else { - Object.entries(value).forEach(([index, v]) => { - if (v !== null && v !== '' && v !== undefined) { - this.append(`${name}[${index}]`, v); - } - }); + entries = Object.assign(Object.assign({}, entries), buildQueryStringEntries(iValue, entries, key)); } + }); + 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) + return (data[key] = value); + if (data[first] === undefined) { + data[first] = Number.isNaN(second) ? {} : []; + } + insertDotNotatedValueIntoData([second, ...rest].join('.'), value, data[first]); + }; + const entries = search.split('&').map((i) => i.split('=')); + const data = {}; + entries.forEach(([key, value]) => { + if (!value) + return; + value = decodeURIComponent(value.replace(/\+/g, '%20')); + if (!key.includes('[')) { + data[key] = value; + } + else { + 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); } - delete(name) { - super.delete(name); - const pattern = new RegExp(`^${name}(\\[.*])?$`); - for (const key of Array.from(this.keys())) { - if (key.match(pattern)) { - super.delete(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); } } -function setQueryParam(param, value) { - const queryParams = new AdvancedURLSearchParams(window.location.search); - queryParams.set(param, value); - const url = urlFromQueryParams(queryParams); - history.replaceState(history.state, '', url); -} -function removeQueryParam(param) { - const queryParams = new AdvancedURLSearchParams(window.location.search); - queryParams.delete(param); - const url = urlFromQueryParams(queryParams); - history.replaceState(history.state, '', url); -} -function urlFromQueryParams(queryParams) { - let queryString = ''; - if (Array.from(queryParams.entries()).length > 0) { - queryString += '?' + queryParams.toString(); +class HistoryStrategy { + static replace(url) { + history.replaceState(history.state, '', url); } - return window.location.origin + window.location.pathname + queryString + window.location.hash; } +class Tracker { + constructor(mapping, initialValue, initiallyPresentInUrl) { + this.mapping = mapping; + this.initialValue = JSON.stringify(initialValue); + this.initiallyPresentInUrl = initiallyPresentInUrl; + } + hasReturnedToInitialValue(currentValue) { + return JSON.stringify(currentValue) === this.initialValue; + } +} class QueryStringPlugin { constructor(mapping) { - this.mapping = new Map; - this.initialPropsValues = new Map; - this.changedProps = {}; - Object.entries(mapping).forEach(([key, config]) => { - this.mapping.set(key, config); - }); + this.mapping = mapping; + this.trackers = new Map; } attachToComponent(component) { component.on('connect', (component) => { - for (const model of this.mapping.keys()) { - for (const prop of this.getNormalizedPropNames(component.valueStore.get(model), model)) { - this.initialPropsValues.set(prop, component.valueStore.get(prop)); - } - } + const urlUtils = new UrlUtils(window.location.href); + Object.entries(this.mapping).forEach(([prop, mapping]) => { + const tracker = new Tracker(mapping, component.valueStore.get(prop), urlUtils.has(prop)); + this.trackers.set(prop, tracker); + }); }); component.on('render:finished', (component) => { - this.initialPropsValues.forEach((initialValue, prop) => { - var _a; + const urlUtils = new UrlUtils(window.location.href); + this.trackers.forEach((tracker, prop) => { const value = component.valueStore.get(prop); - (_a = this.changedProps)[prop] || (_a[prop] = JSON.stringify(value) !== JSON.stringify(initialValue)); - if (this.changedProps) { - this.updateUrlParam(prop, value); + if (!tracker.initiallyPresentInUrl && tracker.hasReturnedToInitialValue(value)) { + urlUtils.remove(tracker.mapping.name); + } + else { + urlUtils.set(tracker.mapping.name, value); } }); + HistoryStrategy.replace(urlUtils); }); } - updateUrlParam(model, value) { - const paramName = this.getParamFromModel(model); - if (paramName === undefined) { - return; - } - this.isValueEmpty(value) - ? removeQueryParam(paramName) - : setQueryParam(paramName, value); - } - getParamFromModel(model) { - const modelParts = model.split('.'); - const rootPropMapping = this.mapping.get(modelParts[0]); - if (rootPropMapping === undefined) { - return undefined; - } - return rootPropMapping.name + modelParts.slice(1).map((v) => `[${v}]`).join(''); - } - *getNormalizedPropNames(value, propertyPath) { - if (this.isObjectValue(value)) { - for (const key in value) { - yield* this.getNormalizedPropNames(value[key], `${propertyPath}.${key}`); - } - } - else { - yield propertyPath; - } - } - isValueEmpty(value) { - return (value === '' || value === null || value === undefined); - } - isObjectValue(value) { - return !(Array.isArray(value) || value === null || typeof value !== 'object'); - } } const getComponent = (element) => LiveControllerDefault.componentRegistry.getComponent(element); diff --git a/src/LiveComponent/assets/dist/url_utils.d.ts b/src/LiveComponent/assets/dist/url_utils.d.ts index e5938390069..c54c70f08ac 100644 --- a/src/LiveComponent/assets/dist/url_utils.d.ts +++ b/src/LiveComponent/assets/dist/url_utils.d.ts @@ -1,2 +1,11 @@ -export declare function setQueryParam(param: string, value: any): void; -export declare function removeQueryParam(param: string): void; +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/plugins/QueryStringPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts index d1fca7b6ca7..8dced69f4da 100644 --- a/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts +++ b/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts @@ -1,100 +1,59 @@ import Component from '../index'; import { PluginInterface } from './PluginInterface'; -import { - setQueryParam, removeQueryParam, -} from '../../url_utils'; +import { UrlUtils, HistoryStrategy } from '../../url_utils'; -type QueryMapping = { +interface QueryMapping { + /** + * URL parameter name + */ name: string, } +/** + * Tracks initial state and prop query mapping. + */ +class Tracker { + readonly mapping: QueryMapping; + private readonly initialValue: any; + readonly initiallyPresentInUrl: boolean; + + constructor(mapping: QueryMapping, initialValue: any, initiallyPresentInUrl: boolean) { + this.mapping = mapping; + this.initialValue = JSON.stringify(initialValue); + this.initiallyPresentInUrl = initiallyPresentInUrl; + } + hasReturnedToInitialValue(currentValue: any) { + return JSON.stringify(currentValue) === this.initialValue; + } +} + export default class implements PluginInterface { - private mapping = new Map; - private initialPropsValues = new Map; - private changedProps: {[p: string]: boolean} = {}; + private trackers = new Map; - constructor(mapping: {[p: string]: any}) { - Object.entries(mapping).forEach(([key, config]) => { - this.mapping.set(key, config); - }) + constructor(private readonly mapping: {[p: string]: QueryMapping}) { } attachToComponent(component: Component): void { component.on('connect', (component: Component) => { - // Store initial values of mapped props - for (const model of this.mapping.keys()) { - for (const prop of this.getNormalizedPropNames(component.valueStore.get(model), model)) { - this.initialPropsValues.set(prop, component.valueStore.get(prop)); - } - } + const urlUtils = new UrlUtils(window.location.href); + Object.entries(this.mapping).forEach(([prop, mapping]) => { + const tracker = new Tracker(mapping, component.valueStore.get(prop), urlUtils.has(prop)); + this.trackers.set(prop, tracker); + }); }); component.on('render:finished', (component: Component) => { - this.initialPropsValues.forEach((initialValue, prop) => { + const urlUtils = new UrlUtils(window.location.href); + this.trackers.forEach((tracker, prop) => { const value = component.valueStore.get(prop); - - // Only update the URL if the prop has changed - this.changedProps[prop] ||= JSON.stringify(value) !== JSON.stringify(initialValue); - if (this.changedProps) { - this.updateUrlParam(prop, value); + if (!tracker.initiallyPresentInUrl && tracker.hasReturnedToInitialValue(value)) { + urlUtils.remove(tracker.mapping.name); + } else { + urlUtils.set(tracker.mapping.name, value); } }); - }); - } - - private updateUrlParam(model: string, value: any) - { - const paramName = this.getParamFromModel(model); - - if (paramName === undefined) { - return; - } - - this.isValueEmpty(value) - ? removeQueryParam(paramName) - : setQueryParam(paramName, value); - } - - /** - * Convert a normalized property path (foo.bar) in brace notation (foo[bar]). - */ - private getParamFromModel(model: string) - { - const modelParts = model.split('.'); - const rootPropMapping = this.mapping.get(modelParts[0]); - - if (rootPropMapping === undefined) { - return undefined; - } - - return rootPropMapping.name + modelParts.slice(1).map((v) => `[${v}]`).join(''); - } - /** - * Get property names for the given value in the "foo.bar" format: - * - * getNormalizedPropNames({'foo': ..., 'baz': ...}, 'prop') yields 'prop.foo', 'prop.baz', etc. - * - * Non-object values will yield the propertyPath without any change. - */ - private *getNormalizedPropNames(value: any, propertyPath: string): Generator - { - if (this.isObjectValue(value)) { - for (const key in value) { - yield* this.getNormalizedPropNames(value[key], `${propertyPath}.${key}`) - } - } else { - yield propertyPath; - } - } - - private isValueEmpty(value: any) - { - return (value === '' || value === null || value === undefined); - } - - private isObjectValue(value: any): boolean - { - return !(Array.isArray(value) || value === null || typeof value !== 'object'); + HistoryStrategy.replace(urlUtils); + }); } } diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index 44ea15734f1..2a0e2a445b2 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -56,7 +56,7 @@ export default class LiveControllerDefault extends Controller imple declare readonly hasDebounceValue: boolean; declare readonly debounceValue: number; declare readonly fingerprintValue: string; - declare readonly queryMappingValue: Map; + declare readonly queryMappingValue: { [p: string]: { name: string } }; /** The component, wrapped in the convenience Proxy */ private proxiedComponent: Component; diff --git a/src/LiveComponent/assets/src/url_utils.ts b/src/LiveComponent/assets/src/url_utils.ts index 7d17f5d5da5..21b08a8c68a 100644 --- a/src/LiveComponent/assets/src/url_utils.ts +++ b/src/LiveComponent/assets/src/url_utils.ts @@ -1,59 +1,135 @@ -class AdvancedURLSearchParams extends URLSearchParams { - set(name: string, value: any) { - if (typeof value !== 'object') { - super.set(name, value); - } else { - this.delete(name); - if (Array.isArray(value)) { - value.forEach((v) => { - this.append(`${name}[]`, v); - }); +function isObject(subject: any) { + return typeof subject === 'object' && subject !== null; +} + +/** + * 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 (!isObject(iValue)) { + if (iValue !== null) { + entries[key] = encodeURIComponent(iValue) + .replace(/%20/g, '+') // Conform to RFC1738 + .replace(/%2C/g, ','); + } } else { - Object.entries(value).forEach(([index, v]) => { - if (v !== null && v !== '' && v !== undefined) { - this.append(`${name}[${index}]`, v as string); - } - }); + entries = { ...entries, ...buildQueryStringEntries(iValue, entries, key) }; } + }); + + 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) return (data[key] = value); + + // This is where we fill in empty arrays/objects along the way to the assigment... + if (data[first] === undefined) { + data[first] = Number.isNaN(second) ? {} : []; } - } - delete(name: string) { - super.delete(name); - const pattern = new RegExp(`^${name}(\\[.*])?$`); - for (const key of Array.from(this.keys())) { - if (key.match(pattern)) { - super.delete(key); - } + // 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]) => { + // Query string params don't always have values... (`?foo=`) + if (!value) return; + + value = decodeURIComponent(value.replace(/\+/g, '%20')); + + if (!key.includes('[')) { + data[key] = value; + } else { + // Convert to dot notation because it's easier... + const dotNotatedKey = key.replace(/\[/g, '.').replace(/]/g, ''); + + insertDotNotatedValueIntoData(dotNotatedKey, value, data); } - } + }); + + return data; } -export function setQueryParam(param: string, value: any) { - const queryParams = new AdvancedURLSearchParams(window.location.search); +/** + * Wraps a URL to manage search parameters with common map functions. + */ +export class UrlUtils extends URL { + has(key: string) { + const data = this.getData(); - queryParams.set(param, value); + return Object.keys(data).includes(key); + } - const url = urlFromQueryParams(queryParams); + set(key: string, value: any) { + const data = this.getData(); - history.replaceState(history.state, '', url); -} + data[key] = value; + + this.setData(data); + } -export function removeQueryParam(param: string) { - const queryParams = new AdvancedURLSearchParams(window.location.search); + get(key: string): any | undefined { + return this.getData()[key]; + } - queryParams.delete(param); + remove(key: string) { + const data = this.getData(); - const url = urlFromQueryParams(queryParams); + delete data[key]; - history.replaceState(history.state, '', url); -} + this.setData(data); + } + + private getData() { + if (!this.search) { + return {}; + } -function urlFromQueryParams(queryParams: URLSearchParams) { - let queryString = ''; - if (Array.from(queryParams.entries()).length > 0) { - queryString += '?' + queryParams.toString(); + return fromQueryString(this.search); } - return window.location.origin + window.location.pathname + queryString + window.location.hash; + 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/controller/query-binding.test.ts b/src/LiveComponent/assets/test/controller/query-binding.test.ts index 4f5a6c516e2..7ed6886f174 100644 --- a/src/LiveComponent/assets/test/controller/query-binding.test.ts +++ b/src/LiveComponent/assets/test/controller/query-binding.test.ts @@ -87,7 +87,7 @@ describe('LiveController query string binding', () => { await test.component.set('prop', ['foo', 'bar'], true); - expectCurrentSearch().toEqual('?prop[]=foo&prop[]=bar'); + expectCurrentSearch().toEqual('?prop[0]=foo&prop[1]=bar'); // Remove one value test.expectsAjaxCall() @@ -95,7 +95,7 @@ describe('LiveController query string binding', () => { await test.component.set('prop', ['foo'], true); - expectCurrentSearch().toEqual('?prop[]=foo'); + expectCurrentSearch().toEqual('?prop[0]=foo'); // Remove all remaining values test.expectsAjaxCall() diff --git a/src/LiveComponent/assets/test/url_utils.test.ts b/src/LiveComponent/assets/test/url_utils.test.ts index 605132026b7..4b098b8b46c 100644 --- a/src/LiveComponent/assets/test/url_utils.test.ts +++ b/src/LiveComponent/assets/test/url_utils.test.ts @@ -9,77 +9,101 @@ 'use strict'; -import {setQueryParam, removeQueryParam} from '../src/url_utils'; -import { expectCurrentSearch, setCurrentSearch } from './tools'; +import { HistoryStrategy, UrlUtils } from '../src/url_utils'; +describe('url_utils', () => { + describe('UrlUtils', () => { + describe('set', () => { + const urlUtils: UrlUtils = new UrlUtils(window.location.href); -describe('setQueryParam', () => { - it('set the param if it does not exist', () => { - setCurrentSearch(''); + beforeEach(() => { + // Reset search before each test + urlUtils.search = ''; + }); - setQueryParam('param', 'foo'); + it('set the param if it does not exist', () => { + urlUtils.set('param', 'foo'); - expectCurrentSearch().toEqual('?param=foo'); - }); - - it('override the param if it exists', () => { - setCurrentSearch('?param=foo'); + expect(urlUtils.search).toEqual('?param=foo'); + }); - setQueryParam('param', 'bar'); + it('override the param if it exists', () => { + urlUtils.search = '?param=foo'; - expectCurrentSearch().toEqual('?param=bar'); - }); + urlUtils.set('param', 'bar'); - it('expand arrays in the URL', () => { - setCurrentSearch(''); + expect(urlUtils.search).toEqual('?param=bar'); + }); - setQueryParam('param', ['foo', 'bar']); + it('expand arrays in the URL', () => { + urlUtils.set('param', ['foo', 'bar']); - expectCurrentSearch().toEqual('?param[]=foo¶m[]=bar'); - }); + expect(urlUtils.search).toEqual('?param[0]=foo¶m[1]=bar'); + }); - it('expand objects in the URL', () => { - setCurrentSearch(''); + it('expand objects in the URL', () => { + urlUtils.set('param', { + foo: 1, + bar: 'baz', + }); - setQueryParam('param', { - foo: 1, - bar: 'baz', + expect(urlUtils.search).toEqual('?param[foo]=1¶m[bar]=baz'); + }); }); - expectCurrentSearch().toEqual('?param[foo]=1¶m[bar]=baz'); - }) -}) + describe('remove', () => { + const urlUtils: UrlUtils = new UrlUtils(window.location.href); -describe('removeQueryParam', () => { - it('remove the param if it exists', () => { - setCurrentSearch('?param=foo'); + beforeEach(() => { + // Reset search before each test + urlUtils.search = ''; + }); + it('remove the param if it exists', () => { + urlUtils.search = '?param=foo'; - removeQueryParam('param'); + urlUtils.remove('param'); - expectCurrentSearch().toEqual(''); - }); + expect(urlUtils.search).toEqual(''); + }); - it('keep other params unchanged', () => { - setCurrentSearch('?param=foo&otherParam=bar'); + it('keep other params unchanged', () => { + urlUtils.search ='?param=foo&otherParam=bar'; - removeQueryParam('param'); + urlUtils.remove('param'); - expectCurrentSearch().toEqual('?otherParam=bar'); - }); + expect(urlUtils.search).toEqual('?otherParam=bar'); + }); - it('remove all occurrences of an array param', () => { - setCurrentSearch('?param[]=foo¶m[]=bar'); + it('remove all occurrences of an array param', () => { + urlUtils.search = '?param[0]=foo¶m[1]=bar'; - removeQueryParam('param'); + urlUtils.remove('param'); - expectCurrentSearch().toEqual(''); - }); + expect(urlUtils.search).toEqual(''); + }); - it ('remove all occurrences of an object param', () => { - setCurrentSearch('?param[foo]=1¶m[bar]=baz'); + it ('remove all occurrences of an object param', () => { + urlUtils.search ='?param[foo]=1¶m[bar]=baz'; - removeQueryParam('param'); + urlUtils.remove('param'); - expectCurrentSearch().toEqual(''); + expect(urlUtils.search).toEqual(''); + }); + }); }); -}) \ No newline at end of file + + 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()); + }); + }) +}); diff --git a/src/LiveComponent/src/Util/QueryStringPropsExtractor.php b/src/LiveComponent/src/Util/QueryStringPropsExtractor.php index 04b1e0b4570..71cfb094d82 100644 --- a/src/LiveComponent/src/Util/QueryStringPropsExtractor.php +++ b/src/LiveComponent/src/Util/QueryStringPropsExtractor.php @@ -40,12 +40,20 @@ public function extract(Request $request, LiveComponentMetadata $metadata, objec foreach ($metadata->getAllLivePropsMetadata() as $livePropMetadata) { $queryStringBinding = $livePropMetadata->getQueryStringMapping(); foreach ($queryStringBinding['parameters'] ?? [] as $parameterName => $paramConfig) { - if (isset($query[$parameterName])) { - $data[$paramConfig['property']] = $this->hydrator->hydrateValue($query[$parameterName], $livePropMetadata, $component); + if (null !== ($value = $query[$parameterName] ?? null)) { + if (\is_array($value) && $this->isNumericIndexedArray($value)) { + ksort($value); + } + $data[$paramConfig['property']] = $this->hydrator->hydrateValue($value, $livePropMetadata, $component); } } } return $data; } + + private function isNumericIndexedArray(array $array): bool + { + return 0 === \count(array_filter(array_keys($array), 'is_string')); + } }