Skip to content

Commit f18d797

Browse files
committed
[Live] Always send HTML over the wire instead of JSON
1 parent 303c82f commit f18d797

File tree

10 files changed

+74
-134
lines changed

10 files changed

+74
-134
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# CHANGELOG
22

3+
## 2.1.0
4+
5+
- The Live Component AJAX endpoints now return HTML in all situations
6+
instead of JSON.
7+
38
## 2.0.0
49

510
- Support for `stimulus` version 2 was removed and support for `@hotwired/stimulus`

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 12 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,6 @@ import { buildFormData, buildSearchParams } from './http_data_helper';
66
import { setDeepData, doesDeepPropertyExist, normalizeModelName } from './set_deep_data';
77
import { haveRenderedValuesChanged } from './have_rendered_values_changed';
88

9-
interface LiveResponseData {
10-
redirect_url?: string,
11-
html?: string,
12-
data?: any,
13-
}
14-
159
interface ElementLoadingDirectives {
1610
element: HTMLElement,
1711
directives: Directive[]
@@ -195,20 +189,8 @@ export default class extends Controller {
195189
this._makeRequest(null);
196190
}
197191

198-
_getValueFromElement(element: HTMLElement){
199-
const value = element.dataset.value || element.value;
200-
201-
if (!value) {
202-
const clonedElement = (element.cloneNode());
203-
// helps typescript know this is an HTMLElement
204-
if (!(clonedElement instanceof HTMLElement)) {
205-
throw new Error('cloneNode() produced incorrect type');
206-
}
207-
208-
throw new Error(`The update() method could not be called for "${clonedElement.outerHTML}": the element must either have a "data-value" or "value" attribute set.`);
209-
}
210-
211-
return value;
192+
_getValueFromElement(element: HTMLElement) {
193+
return element.dataset.value || element.value;
212194
}
213195

214196
_updateModelFromElement(element: HTMLElement, value: string, shouldRender: boolean) {
@@ -320,7 +302,7 @@ export default class extends Controller {
320302

321303
const fetchOptions: RequestInit = {};
322304
fetchOptions.headers = {
323-
'Accept': 'application/vnd.live-component+json',
305+
'Accept': 'application/vnd.live-component+html',
324306
};
325307

326308
if (action) {
@@ -351,8 +333,8 @@ export default class extends Controller {
351333

352334
const isMostRecent = this.renderPromiseStack.removePromise(thisPromise);
353335
if (isMostRecent) {
354-
response.json().then((data) => {
355-
this._processRerender(data)
336+
response.text().then((html) => {
337+
this._processRerender(html, response);
356338
});
357339
}
358340
})
@@ -363,24 +345,24 @@ export default class extends Controller {
363345
*
364346
* @private
365347
*/
366-
_processRerender(data: LiveResponseData) {
348+
_processRerender(html: string, response: Response) {
367349
// check if the page is navigating away
368350
if (this.isWindowUnloaded) {
369351
return;
370352
}
371353

372-
if (data.redirect_url) {
354+
if (response.headers.get('Location')) {
373355
// action returned a redirect
374356
if (typeof Turbo !== 'undefined') {
375-
Turbo.visit(data.redirect_url);
357+
Turbo.visit(response.headers.get('Location'));
376358
} else {
377-
window.location.href = data.redirect_url;
359+
window.location.href = response.headers.get('Location');
378360
}
379361

380362
return;
381363
}
382364

383-
if (!this._dispatchEvent('live:render', data, true, true)) {
365+
if (!this._dispatchEvent('live:render', html, true, true)) {
384366
// preventDefault() was called
385367
return;
386368
}
@@ -390,15 +372,8 @@ export default class extends Controller {
390372
// elements to appear different unnecessarily
391373
this._onLoadingFinish();
392374

393-
if (!data.html) {
394-
throw new Error('Missing html key on response JSON');
395-
}
396-
397375
// merge/patch in the new HTML
398-
this._executeMorphdom(data.html);
399-
400-
// "data" holds the new, updated data
401-
this.dataValue = data.data;
376+
this._executeMorphdom(html);
402377
}
403378

404379
_clearWaitingDebouncedRenders() {
@@ -644,7 +619,7 @@ export default class extends Controller {
644619
this.pollingIntervals.push(timer);
645620
}
646621

647-
_dispatchEvent(name: string, payload: object | null = null, canBubble = true, cancelable = false) {
622+
_dispatchEvent(name: string, payload: object | string | null = null, canBubble = true, cancelable = false) {
648623
return this.element.dispatchEvent(new CustomEvent(name, {
649624
bubbles: canBubble,
650625
cancelable,

src/LiveComponent/assets/test/controller/action.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,10 @@ describe('LiveController Action Tests', () => {
4848
const { element } = await startStimulus(template(data));
4949

5050
// ONLY a post is sent, not a re-render GET
51-
const postMock = fetchMock.postOnce('http://localhost/_components/my_component/save', {
52-
html: template({ comments: 'hi weaver', isSaved: true }),
53-
data: { comments: 'hi weaver', isSaved: true }
54-
});
51+
const postMock = fetchMock.postOnce(
52+
'http://localhost/_components/my_component/save',
53+
template({ comments: 'hi weaver', isSaved: true })
54+
);
5555

5656
await userEvent.type(getByLabelText(element, 'Comments:'), ' WEAVER');
5757

src/LiveComponent/assets/test/controller/child.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ describe('LiveController parent -> child component tests', () => {
133133
const inputElement = getByLabelText(element, 'Content:');
134134
await userEvent.clear(inputElement);
135135
await userEvent.type(inputElement, 'changed content');
136+
// change the rows on the server
136137
mockRerender({'value': 'changed content'}, childTemplate, (data) => {
137138
data.rows = 5;
138139
});

src/LiveComponent/assets/test/controller/csrf.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ describe('LiveController CSRF Tests', () => {
4747
const data = { comments: 'hi' };
4848
const { element } = await startStimulus(template(data));
4949

50-
const postMock = fetchMock.postOnce('http://localhost/_components/my_component/save', {
51-
html: template({ comments: 'hi', isSaved: true }),
52-
data: { comments: 'hi', isSaved: true }
53-
});
50+
const postMock = fetchMock.postOnce(
51+
'http://localhost/_components/my_component/save',
52+
template({ comments: 'hi', isSaved: true })
53+
);
5454
getByText(element, 'Save').click();
5555

5656
await waitFor(() => expect(element).toHaveTextContent('Comment Saved!'));

src/LiveComponent/assets/test/controller/model.test.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,7 @@ describe('LiveController data-model Tests', () => {
4141
const data = { name: 'Ryan' };
4242
const { element, controller } = await startStimulus(template(data));
4343

44-
fetchMock.getOnce('end:?name=Ryan+WEAVER', {
45-
html: template({ name: 'Ryan Weaver' }),
46-
data: { name: 'Ryan Weaver' }
47-
});
44+
fetchMock.getOnce('end:?name=Ryan+WEAVER', template({ name: 'Ryan Weaver' }));
4845

4946
await userEvent.type(getByLabelText(element, 'Name:'), ' WEAVER', {
5047
// this tests the debounce: characters have a 10ms delay
@@ -66,10 +63,7 @@ describe('LiveController data-model Tests', () => {
6663
const data = { name: 'Ryan' };
6764
const { element, controller } = await startStimulus(template(data));
6865

69-
fetchMock.getOnce('end:?name=Jan', {
70-
html: template({ name: 'Jan' }),
71-
data: { name: 'Jan' }
72-
});
66+
fetchMock.getOnce('end:?name=Jan', template({ name: 'Jan' }));
7367

7468
userEvent.click(getByText(element, 'Change name to Jan'));
7569

@@ -96,12 +90,11 @@ describe('LiveController data-model Tests', () => {
9690
['guy', 150]
9791
];
9892
requests.forEach(([string, delay]) => {
99-
fetchMock.getOnce(`end:my_component?name=Ryan${string}`, {
100-
// the _ at the end helps us look that the input has changed
101-
// as a result of a re-render (not just from typing in the input)
102-
html: template({ name: `Ryan${string}_` }),
103-
data: { name: `Ryan${string}_` }
104-
}, { delay });
93+
fetchMock.getOnce(
94+
`end:my_component?name=Ryan${string}`,
95+
template({ name: `Ryan${string}_` }),
96+
{ delay }
97+
);
10598
});
10699

107100
await userEvent.type(getByLabelText(element, 'Name:'), 'guy', {
@@ -275,4 +268,22 @@ describe('LiveController data-model Tests', () => {
275268
// assert all calls were done the correct number of times
276269
fetchMock.done();
277270
});
271+
272+
it('data changed on server should be noticed by controller', async () => {
273+
const data = { name: 'Ryan' };
274+
const { element, controller } = await startStimulus(template(data));
275+
276+
mockRerender({name: 'Ryan WEAVER'}, template, (data) => {
277+
// sneaky server changes the data!
278+
data.name = 'Kevin Bond';
279+
});
280+
281+
const inputElement = getByLabelText(element, 'Name:');
282+
await userEvent.type(inputElement, ' WEAVER');
283+
284+
await waitFor(() => expect(inputElement).toHaveValue('Kevin Bond'));
285+
expect(controller.dataValue).toEqual({name: 'Kevin Bond'});
286+
287+
fetchMock.done();
288+
});
278289
});

src/LiveComponent/assets/test/controller/render.test.ts

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,11 @@ describe('LiveController rendering Tests', () => {
6161
const data = { name: 'Ryan' };
6262
const { element } = await startStimulus(template(data));
6363

64-
fetchMock.get('http://localhost/_components/my_component?name=Ryan', {
65-
html: template({ name: 'Kevin' }),
66-
data: { name: 'Kevin' }
67-
}, {
68-
delay: 100
69-
});
64+
fetchMock.get(
65+
'http://localhost/_components/my_component?name=Ryan',
66+
template({ name: 'Kevin' }),
67+
{ delay: 100 }
68+
);
7069
getByText(element, 'Reload').click();
7170
userEvent.type(getByLabelText(element, 'Comments:'), '!!');
7271

@@ -84,12 +83,11 @@ describe('LiveController rendering Tests', () => {
8483
template(data, true)
8584
);
8685

87-
fetchMock.get('http://localhost/_components/my_component?name=Ryan', {
88-
html: template({ name: 'Kevin' }, true),
89-
data: { name: 'Kevin' }
90-
}, {
91-
delay: 100
92-
});
86+
fetchMock.get(
87+
'http://localhost/_components/my_component?name=Ryan',
88+
template({ name: 'Kevin' }, true),
89+
{ delay: 100 }
90+
);
9391
getByText(element, 'Reload').click();
9492
userEvent.type(getByLabelText(element, 'Comments:'), '!!');
9593

@@ -102,10 +100,7 @@ describe('LiveController rendering Tests', () => {
102100
const data = { name: 'Ryan' };
103101
const { element } = await startStimulus(template(data));
104102

105-
fetchMock.get('end:?name=Ryan', {
106-
html: '<div>aloha!</div>',
107-
data: { name: 'Kevin' }
108-
}, {
103+
fetchMock.get('end:?name=Ryan', '<div>aloha!</div>', {
109104
delay: 100
110105
});
111106

src/LiveComponent/assets/test/tools.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,7 @@ const mockRerender = (sentData, renderCallback, changeDataCallback = null) => {
7474
changeDataCallback(sentData);
7575
}
7676

77-
fetchMock.mock(url, {
78-
html: renderCallback(sentData),
79-
data: sentData
80-
});
77+
fetchMock.mock(url, renderCallback(sentData));
8178
}
8279

8380
export { startStimulus, getControllerElement, initLiveComponent, mockRerender };

src/LiveComponent/src/EventListener/LiveComponentSubscriber.php

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313

1414
use Psr\Container\ContainerInterface;
1515
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
16-
use Symfony\Component\HttpFoundation\JsonResponse;
1716
use Symfony\Component\HttpFoundation\Request;
1817
use Symfony\Component\HttpFoundation\Response;
1918
use Symfony\Component\HttpKernel\Event\ControllerEvent;
@@ -41,8 +40,7 @@
4140
*/
4241
class LiveComponentSubscriber implements EventSubscriberInterface, ServiceSubscriberInterface
4342
{
44-
private const JSON_FORMAT = 'live-component-json';
45-
private const JSON_CONTENT_TYPE = 'application/vnd.live-component+json';
43+
private const HTML_CONTENT_TYPE = 'application/vnd.live-component+html';
4644

4745
private ContainerInterface $container;
4846

@@ -69,8 +67,6 @@ public function onKernelRequest(RequestEvent $event): void
6967
return;
7068
}
7169

72-
$request->setFormat(self::JSON_FORMAT, self::JSON_CONTENT_TYPE);
73-
7470
// the default "action" is get, which does nothing
7571
$action = $request->get('action', 'get');
7672
$componentName = (string) $request->get('component');
@@ -192,16 +188,17 @@ public function onKernelResponse(ResponseEvent $event): void
192188
return;
193189
}
194190

195-
if (!$this->isLiveComponentJsonRequest($request)) {
191+
if (!\in_array(self::HTML_CONTENT_TYPE, $request->getAcceptableContentTypes(), true)) {
196192
return;
197193
}
198194

199195
if (!$response->isRedirection()) {
200196
return;
201197
}
202198

203-
$event->setResponse(new JsonResponse([
204-
'redirect_url' => $response->headers->get('Location'),
199+
$event->setResponse(new Response(null, 204, [
200+
'Location' => $response->headers->get('Location'),
201+
'Content-Type' => self::HTML_CONTENT_TYPE,
205202
]));
206203
}
207204

@@ -227,27 +224,11 @@ private function createResponse(object $component, Request $request): Response
227224
$request->attributes->get('_component_template')
228225
);
229226

230-
if ($this->isLiveComponentJsonRequest($request)) {
231-
return new JsonResponse(
232-
[
233-
'html' => $html,
234-
'data' => $this->container->get(LiveComponentHydrator::class)->dehydrate($component),
235-
],
236-
200,
237-
['Content-Type' => self::JSON_CONTENT_TYPE]
238-
);
239-
}
240-
241227
return new Response($html);
242228
}
243229

244230
private function isLiveComponentRequest(Request $request): bool
245231
{
246232
return 'live_component' === $request->attributes->get('_route');
247233
}
248-
249-
private function isLiveComponentJsonRequest(Request $request): bool
250-
{
251-
return \in_array($request->getPreferredFormat(), [self::JSON_FORMAT, 'json'], true);
252-
}
253234
}

0 commit comments

Comments
 (0)