diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index a14f813bcd1..84162cee094 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -24,6 +24,7 @@ ->append([__FILE__]) ->notPath('#/Fixtures/#') ->notPath('#/app/var/#') + ->notPath('#/var/cache/#') ->notPath('Turbo/Attribute/Broadcast.php') // Need https://github.com/FriendsOfPHP/PHP-CS-Fixer/issues/4702 ) ; diff --git a/package.json b/package.json index a3c45faaea8..4f79006e47b 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "plugin:@typescript-eslint/recommended" ], "rules": { - "@typescript-eslint/no-explicit-any": "off" + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-empty-function": "off" }, "env": { "browser": true diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 0b766c29c2c..810078b8203 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -2,6 +2,11 @@ ## 2.1.0 +- Your component's live "data" is now send over Ajax as a JSON string. + Previously data was sent as pure query parameters or as pure POST data. + However, this made it impossible to keep certain data types, like + distinguishing between `null` and `''`. This has no impact on end-users. + - Added `data-live-ignore` attribute. If included in an element, that element will not be updated on re-render. diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 7834400b096..ddc36e20d09 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -898,61 +898,6 @@ function combineSpacedArray(parts) { return finalParts; } -const buildFormKey = function (key, parentKeys) { - let fieldName = ''; - [...parentKeys, key].forEach((name) => { - fieldName += fieldName ? `[${name}]` : name; - }); - return fieldName; -}; -const addObjectToFormData = function (formData, data, parentKeys) { - Object.keys(data).forEach((key => { - let value = data[key]; - if (value === true) { - value = 1; - } - if (value === false) { - value = 0; - } - if (value === null) { - return; - } - if (typeof value === 'object') { - addObjectToFormData(formData, value, [...parentKeys, key]); - return; - } - formData.append(buildFormKey(key, parentKeys), value); - })); -}; -const addObjectToSearchParams = function (searchParams, data, parentKeys) { - Object.keys(data).forEach((key => { - let value = data[key]; - if (value === true) { - value = 1; - } - if (value === false) { - value = 0; - } - if (value === null) { - return; - } - if (typeof value === 'object') { - addObjectToSearchParams(searchParams, value, [...parentKeys, key]); - return; - } - searchParams.set(buildFormKey(key, parentKeys), value); - })); -}; -function buildFormData(data) { - const formData = new FormData(); - addObjectToFormData(formData, data, []); - return formData; -} -function buildSearchParams(searchParams, data) { - addObjectToSearchParams(searchParams, data, []); - return searchParams; -} - function setDeepData(data, propertyPath, value) { const finalData = JSON.parse(JSON.stringify(data)); let currentLevelData = finalData; @@ -1200,13 +1145,19 @@ class default_1 extends Controller { fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfValue; } } - if (!action && this._willDataFitInUrl()) { - buildSearchParams(params, this.dataValue); - fetchOptions.method = 'GET'; + let dataAdded = false; + if (!action) { + const dataJson = JSON.stringify(this.dataValue); + if (this._willDataFitInUrl(dataJson, params)) { + params.set('data', dataJson); + fetchOptions.method = 'GET'; + dataAdded = true; + } } - else { + if (!dataAdded) { fetchOptions.method = 'POST'; - fetchOptions.body = buildFormData(this.dataValue); + fetchOptions.body = JSON.stringify(this.dataValue); + fetchOptions.headers['Content-Type'] = 'application/json'; } this._onLoadingStart(); const paramsString = params.toString(); @@ -1358,8 +1309,9 @@ class default_1 extends Controller { element.removeAttribute(attribute); }); } - _willDataFitInUrl() { - return Object.values(this.dataValue).join(',').length < 1500; + _willDataFitInUrl(dataJson, params) { + const urlEncodedJsonData = new URLSearchParams(dataJson).toString(); + return (urlEncodedJsonData + params.toString()).length < 1500; } _executeMorphdom(newHtml) { function htmlToElement(html) { diff --git a/src/LiveComponent/assets/src/http_data_helper.ts b/src/LiveComponent/assets/src/http_data_helper.ts deleted file mode 100644 index b52ac9d0229..00000000000 --- a/src/LiveComponent/assets/src/http_data_helper.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Helper to convert a deep object of data into a format - * that can be transmitted as GET or POST data. - * - * Likely there is an easier way to do this with no duplication. - */ - -const buildFormKey = function(key: string, parentKeys: string[]) { - let fieldName = ''; - [...parentKeys, key].forEach((name) => { - fieldName += fieldName ? `[${name}]` : name; - }); - - return fieldName; -} - -const addObjectToFormData = function(formData: FormData, data: any, parentKeys: string[]) { - // todo - handles files - Object.keys(data).forEach((key => { - let value = data[key]; - - // TODO: there is probably a better way to normalize this - if (value === true) { - value = 1; - } - if (value === false) { - value = 0; - } - // don't send null values at all - if (value === null) { - return; - } - - // handle embedded objects - if (typeof value === 'object') { - addObjectToFormData(formData, value, [...parentKeys, key]); - - return; - } - - formData.append(buildFormKey(key, parentKeys), value); - })); -} - -const addObjectToSearchParams = function(searchParams: URLSearchParams, data: any, parentKeys: string[]) { - Object.keys(data).forEach((key => { - let value = data[key]; - - // TODO: there is probably a better way to normalize this - // TODO: duplication - if (value === true) { - value = 1; - } - if (value === false) { - value = 0; - } - // don't send null values at all - if (value === null) { - return; - } - - // handle embedded objects - if (typeof value === 'object') { - addObjectToSearchParams(searchParams, value, [...parentKeys, key]); - - return; - } - - searchParams.set(buildFormKey(key, parentKeys), value); - })); -} - -/** - * @param {Object} data - * @return {FormData} - */ -export function buildFormData(data: any): FormData { - const formData = new FormData(); - - addObjectToFormData(formData, data, []); - - return formData; -} - -/** - * @param {URLSearchParams} searchParams - * @param {Object} data - * @return {URLSearchParams} - */ -export function buildSearchParams(searchParams: URLSearchParams, data: any): URLSearchParams { - addObjectToSearchParams(searchParams, data, []); - - return searchParams; -} diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index e13768932bb..680d8842a00 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -2,7 +2,6 @@ import { Controller } from '@hotwired/stimulus'; import morphdom from 'morphdom'; import { parseDirectives, Directive } from './directives_parser'; import { combineSpacedArray } from './string_utils'; -import { buildFormData, buildSearchParams } from './http_data_helper'; import { setDeepData, doesDeepPropertyExist, normalizeModelName } from './set_deep_data'; import { haveRenderedValuesChanged } from './have_rendered_values_changed'; import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison'; @@ -315,12 +314,21 @@ export default class extends Controller { } } - if (!action && this._willDataFitInUrl()) { - buildSearchParams(params, this.dataValue); - fetchOptions.method = 'GET'; - } else { + let dataAdded = false; + if (!action) { + const dataJson = JSON.stringify(this.dataValue); + if (this._willDataFitInUrl(dataJson, params)) { + params.set('data', dataJson); + fetchOptions.method = 'GET'; + dataAdded = true; + } + } + + // if GET can't be used, fallback to POST + if (!dataAdded) { fetchOptions.method = 'POST'; - fetchOptions.body = buildFormData(this.dataValue); + fetchOptions.body = JSON.stringify(this.dataValue); + fetchOptions.headers['Content-Type'] = 'application/json'; } this._onLoadingStart(); @@ -531,9 +539,11 @@ export default class extends Controller { }) } - _willDataFitInUrl() { + _willDataFitInUrl(dataJson: string, params: URLSearchParams) { + const urlEncodedJsonData = new URLSearchParams(dataJson).toString(); + // if the URL gets remotely close to 2000 chars, it may not fit - return Object.values(this.dataValue).join(',').length < 1500; + return (urlEncodedJsonData + params.toString()).length < 1500; } _executeMorphdom(newHtml: string) { diff --git a/src/LiveComponent/assets/test/controller/action.test.ts b/src/LiveComponent/assets/test/controller/action.test.ts index d2a00ff1fe0..fb4e69d4191 100644 --- a/src/LiveComponent/assets/test/controller/action.test.ts +++ b/src/LiveComponent/assets/test/controller/action.test.ts @@ -65,7 +65,8 @@ describe('LiveController Action Tests', () => { await waitFor(() => expect(element).toHaveTextContent('Comment Saved!')); expect(getByLabelText(element, 'Comments:')).toHaveValue('hi weaver'); - expect(postMock.lastOptions().body.get('comments')).toEqual('hi WEAVER'); + const bodyData = JSON.parse(postMock.lastOptions().body); + expect(bodyData.comments).toEqual('hi WEAVER'); }); it('Sends action named args', async () => { diff --git a/src/LiveComponent/assets/test/controller/child.test.ts b/src/LiveComponent/assets/test/controller/child.test.ts index f313c1bb6a8..f1e77698fdc 100644 --- a/src/LiveComponent/assets/test/controller/child.test.ts +++ b/src/LiveComponent/assets/test/controller/child.test.ts @@ -187,7 +187,7 @@ describe('LiveController parent -> child component tests', () => { const inputElement = getByLabelText(element, 'Content:'); await userEvent.clear(inputElement); await userEvent.type(inputElement, 'changed content'); - mockRerender({value: 'changed content'}, childTemplate); + mockRerender({value: 'changed content', error: null}, childTemplate); await waitFor(() => expect(element).toHaveTextContent('Value in child: changed content')); diff --git a/src/LiveComponent/assets/test/controller/model.test.ts b/src/LiveComponent/assets/test/controller/model.test.ts index 3bed409f951..937df1b10b3 100644 --- a/src/LiveComponent/assets/test/controller/model.test.ts +++ b/src/LiveComponent/assets/test/controller/model.test.ts @@ -44,7 +44,9 @@ describe('LiveController data-model Tests', () => { const data = { name: 'Ryan' }; const { element, controller } = await startStimulus(template(data)); - fetchMock.getOnce('end:?name=Ryan+WEAVER', template({ name: 'Ryan Weaver' })); + mockRerender({name: 'Ryan WEAVER'}, template, (data: any) => { + data.name = 'Ryan Weaver'; + }); await userEvent.type(getByLabelText(element, 'Name:'), ' WEAVER', { // this tests the debounce: characters have a 10ms delay @@ -63,7 +65,7 @@ describe('LiveController data-model Tests', () => { const data = { name: 'Ryan' }; const { element, controller } = await startStimulus(template(data)); - fetchMock.getOnce('end:?name=Jan', template({ name: 'Jan' })); + mockRerender({name: 'Jan'}, template); userEvent.click(getByText(element, 'Change name to Jan')); @@ -87,11 +89,9 @@ describe('LiveController data-model Tests', () => { ['guy', 150] ]; requests.forEach(([string, delay]) => { - fetchMock.getOnce( - `end:my_component?name=Ryan${string}`, - template({ name: `Ryan${string}_` }), - { delay } - ); + mockRerender({name: `Ryan${string}`}, template, (data: any) => { + data.name = `Ryan${string}_`; + }, { delay }); }); await userEvent.type(getByLabelText(element, 'Name:'), 'guy', { @@ -121,7 +121,7 @@ describe('LiveController data-model Tests', () => { delete inputElement.dataset.model; inputElement.setAttribute('name', 'name'); - mockRerender({name: 'Ryan WEAVER'}, template, (data) => { + mockRerender({name: 'Ryan WEAVER'}, template, (data: any) => { data.name = 'Ryan Weaver'; }); diff --git a/src/LiveComponent/assets/test/controller/render.test.ts b/src/LiveComponent/assets/test/controller/render.test.ts index f29f0ee4b91..8b6cc6befa1 100644 --- a/src/LiveComponent/assets/test/controller/render.test.ts +++ b/src/LiveComponent/assets/test/controller/render.test.ts @@ -64,13 +64,9 @@ describe('LiveController rendering Tests', () => { const data = { name: 'Ryan' }; const { element } = await startStimulus(template(data)); - fetchMock.get( - // name=Ryan is sent to the server - 'http://localhost/_components/my_component?name=Ryan', - // server changes that data - template({ name: 'Kevin' }), - { delay: 100 } - ); + mockRerender({name: 'Ryan'}, template, (data: any) => { + data.name = 'Kevin'; + }, { delay: 100 }); // type into the input that is not bound to a model userEvent.type(getByLabelText(element, 'Comments:'), '!!'); getByText(element, 'Reload').click(); @@ -85,13 +81,11 @@ describe('LiveController rendering Tests', () => { const data = { name: 'Ryan' }; const { element } = await startStimulus(template(data)); - fetchMock.get( - // name=Ryan is sent to the server - 'http://localhost/_components/my_component?name=Ryan', - // server changes that data - template({ name: 'Kevin' }), - { delay: 100 } - ); + // name=Ryan is sent to the server + mockRerender({name: 'Ryan'}, template, (data: any) => { + data.name = 'Kevin'; + }, { delay: 100 }); + // type into the input that is not bound to a model const input = getByLabelText(element, 'Comments:'); input.setAttribute('data-live-ignore', ''); @@ -113,11 +107,14 @@ describe('LiveController rendering Tests', () => { template(data, true) ); - fetchMock.get( - 'http://localhost/_components/my_component?name=Ryan', - template({ name: 'Kevin' }, true), + mockRerender( + { name: 'Ryan' }, + // re-render but passing true as the second arg + (data: any) => template(data, true), + (data: any) => { data.name = 'Kevin'; }, { delay: 100 } ); + const input = getByLabelText(element, 'Comments:'); input.setAttribute('data-live-ignore', ''); userEvent.type(input, '!!'); @@ -132,9 +129,12 @@ describe('LiveController rendering Tests', () => { const data = { name: 'Ryan' }; const { element } = await startStimulus(template(data)); - fetchMock.get('end:?name=Ryan', '
aloha!
', { - delay: 100 - }); + mockRerender( + { name: 'Ryan' }, + () => '
aloha!
', + () => { }, + { delay: 100 } + ); getByText(element, 'Reload').click(); // imitate navigating away diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts index 4e70af6e376..b3185ac9a92 100644 --- a/src/LiveComponent/assets/test/tools.ts +++ b/src/LiveComponent/assets/test/tools.ts @@ -2,7 +2,7 @@ import { Application } from '@hotwired/stimulus'; import LiveController from '../src/live_controller'; import { waitFor } from '@testing-library/dom'; import fetchMock from 'fetch-mock-jest'; -import { buildSearchParams } from '../src/http_data_helper'; +import MockOptions = jest.MockOptions; const TestData = class { constructor(controller, element) { @@ -64,17 +64,20 @@ const initLiveComponent = (url, data) => { * @param {Object} sentData The *expected* data that should be sent to the server * @param {function} renderCallback Function that will render the component * @param {function|null} changeDataCallback Specify if you want to change the data before rendering + * @param {MockOptions} options Options passed to fetchMock */ -const mockRerender = (sentData, renderCallback, changeDataCallback = null) => { - const params = new URLSearchParams(''); +const mockRerender = (sentData: any, renderCallback, changeDataCallback = null, options: MockOptions = {}) => { + const params = new URLSearchParams({ + data: JSON.stringify(sentData) + }); - const url = `end:?${buildSearchParams(params, sentData).toString()}`; + const url = `end:?${params.toString()}`; if (changeDataCallback) { changeDataCallback(sentData); } - fetchMock.mock(url, renderCallback(sentData)); + fetchMock.mock(url, renderCallback(sentData), options); } export { startStimulus, getControllerElement, initLiveComponent, mockRerender }; diff --git a/src/LiveComponent/src/ComponentWithFormTrait.php b/src/LiveComponent/src/ComponentWithFormTrait.php index 04fbf0e3ac2..0e23ee2cbf1 100644 --- a/src/LiveComponent/src/ComponentWithFormTrait.php +++ b/src/LiveComponent/src/ComponentWithFormTrait.php @@ -178,7 +178,13 @@ private function extractFormValues(FormView $formView): array $values = []; foreach ($formView->children as $child) { $name = $child->vars['name']; - if (\count($child->children) > 0) { + + // if there are children, expand their values recursively + // UNLESS the field is "expanded": in that case the value + // is already correct. For example, an expanded ChoiceType with + // options "text" and "phone" would already have a value in the format + // ["text"] (assuming "text" is checked and "phone" is not). + if (!($child->vars['expanded'] ?? false) && \count($child->children) > 0) { $values[$name] = $this->extractFormValues($child); continue; diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index dd54f30f051..7b92dd26245 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -122,10 +122,13 @@ public function onKernelController(ControllerEvent $event): void return; } - $data = array_merge( - $request->query->all(), - $request->request->all() - ); + if ($request->query->has('data')) { + // ?data= + $data = json_decode($request->query->get('data'), true, 512, \JSON_THROW_ON_ERROR); + } else { + // OR body of the request is JSON + $data = json_decode($request->getContent(), true, 512, \JSON_THROW_ON_ERROR); + } if (!\is_array($controller = $event->getController()) || 2 !== \count($controller)) { throw new \RuntimeException('Not a valid live component.'); diff --git a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php index aecfc844864..710789acb26 100644 --- a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php @@ -54,7 +54,7 @@ public function testCanRenderComponentAsHtml(): void $this->browser() ->throwExceptions() - ->get('/_components/component1?'.http_build_query($dehydrated)) + ->get('/_components/component1?data='.urlencode(json_encode($dehydrated))) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') ->assertContains('Prop1: '.$entity->id) @@ -80,7 +80,7 @@ public function testCanExecuteComponentAction(): void $this->browser() ->throwExceptions() - ->get('/_components/component2?'.http_build_query($dehydrated)) + ->get('/_components/component2?data='.urlencode(json_encode($dehydrated))) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') ->assertContains('Count: 1') @@ -88,8 +88,9 @@ public function testCanExecuteComponentAction(): void // get a valid token to use for actions $token = $response->crawler()->filter('div')->first()->attr('data-live-csrf-value'); }) - ->post('/_components/component2/increase?'.http_build_query($dehydrated), [ + ->post('/_components/component2/increase', [ 'headers' => ['X-CSRF-TOKEN' => $token], + 'body' => json_encode($dehydrated), ]) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') @@ -168,7 +169,7 @@ public function testBeforeReRenderHookOnlyExecutedDuringAjax(): void ->visit('/render-template/template1') ->assertSuccessful() ->assertSee('BeforeReRenderCalled: No') - ->get('/_components/component2?'.http_build_query($dehydrated)) + ->get('/_components/component2?data='.urlencode(json_encode($dehydrated))) ->assertSuccessful() ->assertSee('BeforeReRenderCalled: Yes') ; @@ -190,7 +191,7 @@ public function testCanRedirectFromComponentAction(): void $this->browser() ->throwExceptions() - ->get('/_components/component2?'.http_build_query($dehydrated)) + ->get('/_components/component2?data='.urlencode(json_encode($dehydrated))) ->assertSuccessful() ->use(function (HtmlResponse $response) use (&$token) { // get a valid token to use for actions @@ -198,17 +199,19 @@ public function testCanRedirectFromComponentAction(): void }) ->interceptRedirects() // with no custom header, it redirects like a normal browser - ->post('/_components/component2/redirect?'.http_build_query($dehydrated), [ + ->post('/_components/component2/redirect', [ 'headers' => ['X-CSRF-TOKEN' => $token], + 'body' => json_encode($dehydrated), ]) ->assertRedirectedTo('/') // with custom header, a special 204 is returned - ->post('/_components/component2/redirect?'.http_build_query($dehydrated), [ + ->post('/_components/component2/redirect', [ 'headers' => [ 'Accept' => 'application/vnd.live-component+html', 'X-CSRF-TOKEN' => $token, ], + 'body' => json_encode($dehydrated), ]) ->assertStatus(204) ->assertHeaderEquals('Location', '/') @@ -229,13 +232,10 @@ public function testInjectsLiveArgs(): void $dehydrated = $hydrator->dehydrate($component); $token = null; - $dehydratedWithArgs = array_merge($dehydrated, [ - 'args' => http_build_query(['arg1' => 'hello', 'arg2' => 666, 'custom' => '33.3']), - ]); - + $argsQueryParams = http_build_query(['args' => http_build_query(['arg1' => 'hello', 'arg2' => 666, 'custom' => '33.3'])]); $this->browser() ->throwExceptions() - ->get('/_components/component6?'.http_build_query($dehydrated)) + ->get('/_components/component6?data='.urlencode(json_encode($dehydrated)).'&'.$argsQueryParams) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') ->assertContains('Arg1: not provided') @@ -245,8 +245,9 @@ public function testInjectsLiveArgs(): void // get a valid token to use for actions $token = $response->crawler()->filter('div')->first()->attr('data-live-csrf-value'); }) - ->post('/_components/component6/inject?'.http_build_query($dehydratedWithArgs), [ + ->post('/_components/component6/inject?'.$argsQueryParams, [ 'headers' => ['X-CSRF-TOKEN' => $token], + 'body' => json_encode($dehydrated), ]) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') diff --git a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php index 0409ca0a7d7..949352393e4 100644 --- a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php +++ b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php @@ -46,7 +46,7 @@ public function testFormValuesRebuildAfterFormChanges(): void $token = null; $this->browser() - ->get('/_components/form_with_collection_type?'.http_build_query($dehydrated)) + ->get('/_components/form_with_collection_type?data='.urlencode(json_encode($dehydrated))) ->use(function (HtmlResponse $response) use (&$dehydrated, &$token) { // mimic user typing $dehydrated['blog_post_form']['content'] = 'changed description by user'; @@ -56,7 +56,7 @@ public function testFormValuesRebuildAfterFormChanges(): void // post to action, which will add a new embedded comment ->post('/_components/form_with_collection_type/addComment', [ - 'body' => $dehydrated, + 'body' => json_encode($dehydrated), 'headers' => ['X-CSRF-TOKEN' => $token], ]) ->assertStatus(422) @@ -92,7 +92,7 @@ public function testFormValuesRebuildAfterFormChanges(): void // post to action, which will remove the original embedded comment ->post('/_components/form_with_collection_type/removeComment?'.http_build_query(['args' => 'index=0']), [ - 'body' => $dehydrated, + 'body' => json_encode($dehydrated), 'headers' => ['X-CSRF-TOKEN' => $token], ]) ->assertStatus(422) @@ -136,7 +136,7 @@ public function testFormRemembersValidationFromInitialForm(): void $dehydrated['validatedFields'][] = 'blog_post_form.content'; $this->browser() - ->get('/_components/form_with_collection_type?'.http_build_query($dehydrated)) + ->get('/_components/form_with_collection_type?data='.urlencode(json_encode($dehydrated))) // normal validation happened ->assertContains('The content field is too short') // title is STILL validated as all fields should be validated