Skip to content

Commit 31dd8bd

Browse files
committed
Keep empty params / TwigComponent bc break
1 parent 0bc9e74 commit 31dd8bd

File tree

12 files changed

+131
-99
lines changed

12 files changed

+131
-99
lines changed

src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,5 @@ export default class implements PluginInterface {
99
[p: string]: QueryMapping;
1010
});
1111
attachToComponent(component: Component): void;
12-
private isEmpty;
1312
}
1413
export {};

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 23 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2703,25 +2703,36 @@ class ComponentRegistry {
27032703
}
27042704
}
27052705

2706-
function isObject(subject) {
2707-
return typeof subject === 'object' && subject !== null;
2706+
function isValueEmpty(value) {
2707+
if (null === value || value === '' || undefined === value || (Array.isArray(value) && value.length === 0)) {
2708+
return true;
2709+
}
2710+
if (typeof value !== 'object') {
2711+
return false;
2712+
}
2713+
for (const key of Object.keys(value)) {
2714+
if (!isValueEmpty(value[key])) {
2715+
return false;
2716+
}
2717+
}
2718+
return true;
27082719
}
27092720
function toQueryString(data) {
27102721
const buildQueryStringEntries = (data, entries = {}, baseKey = '') => {
27112722
Object.entries(data).forEach(([iKey, iValue]) => {
27122723
const key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`;
2713-
if (!isObject(iValue)) {
2714-
if (null !== iValue) {
2724+
if ('' === baseKey && isValueEmpty(iValue)) {
2725+
entries[key] = '';
2726+
}
2727+
else if (null !== iValue) {
2728+
if (typeof iValue === 'object') {
2729+
entries = Object.assign(Object.assign({}, entries), buildQueryStringEntries(iValue, entries, key));
2730+
}
2731+
else {
27152732
entries[key] = encodeURIComponent(iValue)
27162733
.replace(/%20/g, '+')
27172734
.replace(/%2C/g, ',');
27182735
}
2719-
else if ('' === baseKey) {
2720-
entries[key] = '';
2721-
}
2722-
}
2723-
else {
2724-
entries = Object.assign(Object.assign({}, entries), buildQueryStringEntries(iValue, entries, key));
27252736
}
27262737
});
27272738
return entries;
@@ -2738,7 +2749,7 @@ function fromQueryString(search) {
27382749
const insertDotNotatedValueIntoData = (key, value, data) => {
27392750
const [first, second, ...rest] = key.split('.');
27402751
if (!second)
2741-
return data[key] = value;
2752+
return (data[key] = value);
27422753
if (data[first] === undefined) {
27432754
data[first] = Number.isNaN(Number.parseInt(second)) ? {} : [];
27442755
}
@@ -2804,32 +2815,13 @@ class QueryStringPlugin {
28042815
const currentUrl = urlUtils.toString();
28052816
Object.entries(this.mapping).forEach(([prop, mapping]) => {
28062817
const value = component.valueStore.get(prop);
2807-
if (this.isEmpty(value)) {
2808-
urlUtils.remove(mapping.name);
2809-
}
2810-
else {
2811-
urlUtils.set(mapping.name, value);
2812-
}
2818+
urlUtils.set(mapping.name, value);
28132819
});
28142820
if (currentUrl !== urlUtils.toString()) {
28152821
HistoryStrategy.replace(urlUtils);
28162822
}
28172823
});
28182824
}
2819-
isEmpty(value) {
2820-
if (null === value || value === '' || value === undefined || Array.isArray(value) && value.length === 0) {
2821-
return true;
2822-
}
2823-
if (typeof value !== 'object') {
2824-
return false;
2825-
}
2826-
for (let key of Object.keys(value)) {
2827-
if (!this.isEmpty(value[key])) {
2828-
return false;
2829-
}
2830-
}
2831-
return true;
2832-
}
28332825
}
28342826

28352827
const getComponent = (element) => LiveControllerDefault.componentRegistry.getComponent(element);

src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,7 @@ export default class implements PluginInterface {
1919

2020
Object.entries(this.mapping).forEach(([prop, mapping]) => {
2121
const value = component.valueStore.get(prop);
22-
if (this.isEmpty(value)) {
23-
urlUtils.remove(mapping.name);
24-
} else {
25-
urlUtils.set(mapping.name, value);
26-
}
22+
urlUtils.set(mapping.name, value);
2723
});
2824

2925
// Only update URL if it has changed
@@ -32,22 +28,4 @@ export default class implements PluginInterface {
3228
}
3329
});
3430
}
35-
36-
private isEmpty(value: any): boolean
37-
{
38-
if (null === value || value === '' || value === undefined || Array.isArray(value) && value.length === 0) {
39-
return true;
40-
}
41-
42-
if (typeof value !== 'object') {
43-
return false;
44-
}
45-
46-
for (let key of Object.keys(value)) {
47-
if (!this.isEmpty(value[key])) {
48-
return false;
49-
}
50-
}
51-
return true;
52-
}
5331
}

src/LiveComponent/assets/src/url_utils.ts

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
1-
function isObject(subject: any) {
2-
return typeof subject === 'object' && subject !== null;
1+
/**
2+
* Adapted from Livewire's history plugin.
3+
*
4+
* @see https://github.com/livewire/livewire/blob/d4839e3b2c23fc71e615e68bc29ff4de95751810/js/plugins/history/index.js
5+
*/
6+
7+
/**
8+
* Check if a value is empty.
9+
*
10+
* Empty values are:
11+
* - `null` and `undefined`
12+
* - Empty strings
13+
* - Empty arrays
14+
* - Deeply empty objects
15+
*/
16+
function isValueEmpty(value: any): boolean {
17+
if (null === value || value === '' || undefined === value || (Array.isArray(value) && value.length === 0)) {
18+
return true;
19+
}
20+
21+
if (typeof value !== 'object') {
22+
return false;
23+
}
24+
25+
for (const key of Object.keys(value)) {
26+
if (!isValueEmpty(value[key])) {
27+
return false;
28+
}
29+
}
30+
31+
return true;
332
}
433

534
/**
@@ -14,17 +43,19 @@ function toQueryString(data: any) {
1443
Object.entries(data).forEach(([iKey, iValue]) => {
1544
const key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`;
1645

17-
if (!isObject(iValue)) {
18-
if (null !== iValue) {
46+
if ('' === baseKey && isValueEmpty(iValue)) {
47+
// Top level empty parameter
48+
entries[key] = '';
49+
} else if (null !== iValue) {
50+
if (typeof iValue === 'object') {
51+
// Non-empty object/array process
52+
entries = { ...entries, ...buildQueryStringEntries(iValue, entries, key) };
53+
} else {
54+
// Scalar value
1955
entries[key] = encodeURIComponent(iValue)
2056
.replace(/%20/g, '+') // Conform to RFC1738
2157
.replace(/%2C/g, ',');
22-
} else if ('' === baseKey) {
23-
// Keep empty values for top level data
24-
entries[key] = '';
2558
}
26-
} else {
27-
entries = { ...entries, ...buildQueryStringEntries(iValue, entries, key) };
2859
}
2960
});
3061

@@ -54,7 +85,7 @@ function fromQueryString(search: string) {
5485
const [first, second, ...rest] = key.split('.');
5586

5687
// We're at a leaf node, let's make the assigment...
57-
if (!second) return data[key] = value;
88+
if (!second) return (data[key] = value);
5889

5990
// This is where we fill in empty arrays/objects along the way to the assigment...
6091
if (data[first] === undefined) {

src/LiveComponent/assets/test/controller/query-binding.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,15 @@ describe('LiveController query string binding', () => {
4747

4848
await test.component.set('prop1', 'foo', true);
4949

50-
expectCurrentSearch().toEqual('?prop1=foo');
50+
expectCurrentSearch().toEqual('?prop1=foo&prop2=');
5151

5252
// Remove value
5353
test.expectsAjaxCall()
5454
.expectUpdatedData({prop1: ''});
5555

5656
await test.component.set('prop1', '', true);
5757

58-
expectCurrentSearch().toEqual('');
58+
expectCurrentSearch().toEqual('?prop1=&prop2=');
5959

6060
// Number
6161

@@ -65,15 +65,15 @@ describe('LiveController query string binding', () => {
6565

6666
await test.component.set('prop2', 42, true);
6767

68-
expectCurrentSearch().toEqual('?prop2=42');
68+
expectCurrentSearch().toEqual('?prop1=&prop2=42');
6969

7070
// Remove value
7171
test.expectsAjaxCall()
7272
.expectUpdatedData({prop2: null});
7373

7474
await test.component.set('prop2', null, true);
7575

76-
expectCurrentSearch().toEqual('');
76+
expectCurrentSearch().toEqual('?prop1=&prop2=');
7777
});
7878

7979
it('updates array props in the URL', async () => {
@@ -103,7 +103,7 @@ describe('LiveController query string binding', () => {
103103

104104
await test.component.set('prop', [], true);
105105

106-
expectCurrentSearch().toEqual('');
106+
expectCurrentSearch().toEqual('?prop=');
107107
});
108108

109109
it('updates objects in the URL', async () => {
@@ -141,7 +141,7 @@ describe('LiveController query string binding', () => {
141141

142142
await test.component.set('prop', { 'foo': null, 'bar': null }, true);
143143

144-
expectCurrentSearch().toEqual('');
144+
expectCurrentSearch().toEqual('?prop=');
145145
});
146146

147147

src/LiveComponent/assets/test/url_utils.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ describe('url_utils', () => {
4747
expect(urlUtils.search).toEqual('?param[0]=foo&param[1]=bar');
4848
});
4949

50-
it('prevent empty values if the param is array', () => {
50+
it('keep empty values if the param is an empty array', () => {
5151
urlUtils.set('param', []);
5252

53-
expect(urlUtils.search).toEqual('');
53+
expect(urlUtils.search).toEqual('?param=');
5454
});
5555

5656
it('expand objects in the URL', () => {
@@ -71,10 +71,10 @@ describe('url_utils', () => {
7171
expect(urlUtils.search).toEqual('?param[bar]=baz');
7272
});
7373

74-
it('prevent empty values if the param is an empty object', () => {
74+
it('keep empty values if the param is an empty object', () => {
7575
urlUtils.set('param', {});
7676

77-
expect(urlUtils.search).toEqual('');
77+
expect(urlUtils.search).toEqual('?param=');
7878
});
7979
});
8080

src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ public static function getSubscribedEvents(): array
4343

4444
public function onPreMount(PreMountEvent $event): void
4545
{
46+
$request = $this->requestStack->getMainRequest();
47+
48+
if (null === $request) {
49+
return;
50+
}
51+
4652
$metadata = new LiveComponentMetadata(
4753
$event->getMetadata(),
4854
$this->metadataFactory->createPropMetadatas(new \ReflectionClass($event->getComponent()::class))
@@ -52,14 +58,8 @@ public function onPreMount(PreMountEvent $event): void
5258
return;
5359
}
5460

55-
$component = $event->getComponent();
56-
57-
$data = $event->getData();
58-
59-
$request = $this->requestStack->getMainRequest();
60-
61-
$queryStringData = $this->queryStringPropsExtractor->extract($request, $metadata, $component);
61+
$queryStringData = $this->queryStringPropsExtractor->extract($request, $metadata, $event->getComponent());
6262

63-
$event->setData(array_merge($data, $queryStringData));
63+
$event->setData(array_merge($event->getData(), $queryStringData));
6464
}
6565
}

src/LiveComponent/src/Util/LiveControllerAttributesCreator.php

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,18 @@ public function attributesForRendering(MountedComponent $mounted, ComponentMetad
108108
}
109109
}
110110

111+
$liveMetadata = $this->metadataFactory->getMetadata($mounted->getName());
112+
113+
if ($liveMetadata->hasQueryStringBindings()) {
114+
$queryMapping = [];
115+
foreach ($liveMetadata->getAllLivePropsMetadata() as $livePropMetadata) {
116+
if ($mapping = $livePropMetadata->getQueryStringMapping()) {
117+
$queryMapping[$livePropMetadata->getName()] = $mapping;
118+
}
119+
}
120+
$attributesCollection->setQueryUrlMapping($queryMapping);
121+
}
122+
111123
$dehydratedProps = $this->dehydrateComponent(
112124
$mounted->getName(),
113125
$mounted->getComponent(),
@@ -121,17 +133,6 @@ public function attributesForRendering(MountedComponent $mounted, ComponentMetad
121133
);
122134
}
123135

124-
$liveMetadata = $this->metadataFactory->getMetadata($mounted->getName());
125-
if ($liveMetadata->hasQueryStringBindings()) {
126-
$queryMapping = [];
127-
foreach ($liveMetadata->getAllLivePropsMetadata() as $livePropMetadata) {
128-
if ($mapping = $livePropMetadata->getQueryStringMapping()) {
129-
$queryMapping[$livePropMetadata->getName()] = $mapping;
130-
}
131-
}
132-
$attributesCollection->setQueryUrlMapping($queryMapping);
133-
}
134-
135136
return $attributesCollection;
136137
}
137138

src/LiveComponent/src/Util/QueryStringPropsExtractor.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ public function __construct(private readonly LiveComponentHydrator $hydrator)
2828
{
2929
}
3030

31+
/**
32+
* Extracts relevant query parameters from the current URL and hydrates them.
33+
*/
3134
public function extract(Request $request, LiveComponentMetadata $metadata, object $component): array
3235
{
3336
$query = $request->query->all();
@@ -41,8 +44,13 @@ public function extract(Request $request, LiveComponentMetadata $metadata, objec
4144
if ($queryStringMapping = $livePropMetadata->getQueryStringMapping()) {
4245
if (null !== ($value = $query[$queryStringMapping['name']] ?? null)) {
4346
if (\is_array($value) && $this->isNumericIndexedArray($value)) {
47+
// Sort numeric array
4448
ksort($value);
49+
} elseif (('' === $value) && (!$livePropMetadata->isBuiltIn() || 'array' === $livePropMetadata->getType())) {
50+
// Cast empty string to empty array for objects and arrays
51+
$value = [];
4552
}
53+
4654
$data[$livePropMetadata->getName()] = $this->hydrator->hydrateValue($value, $livePropMetadata, $component);
4755
}
4856
}

src/TwigComponent/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 2.13.0
44

5+
- [BC BREAK] Add component metadata to `PreMountEvent` and `PostMountEvent`
56
- Added configuration to separate your components into different "namespaces"
67
- Add `outerScope` variable reach variables from the parent template of an
78
"embedded" component.

0 commit comments

Comments
 (0)