Skip to content

Commit 24bd3a9

Browse files
committed
[Live] Rendering any errors in a simple modal
1 parent 217f7eb commit 24bd3a9

File tree

7 files changed

+220
-11
lines changed

7 files changed

+220
-11
lines changed

src/LiveComponent/assets/dist/live_controller.js

+49-4
Original file line numberDiff line numberDiff line change
@@ -1434,18 +1434,21 @@ class default_1 extends Controller {
14341434
const thisPromise = fetch(`${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, fetchOptions);
14351435
const reRenderPromise = new ReRenderPromise(thisPromise, this.unsyncedInputs.clone());
14361436
this.renderPromiseStack.addPromise(reRenderPromise);
1437-
thisPromise.then((response) => {
1437+
thisPromise.then(async (response) => {
14381438
if (action) {
14391439
this.isActionProcessing = false;
14401440
}
1441+
const html = await response.text();
1442+
if (response.headers.get('Content-Type') !== 'application/vnd.live-component+html') {
1443+
this.renderError(html);
1444+
return;
1445+
}
14411446
if (this.renderDebounceTimeout) {
14421447
return;
14431448
}
14441449
const isMostRecent = this.renderPromiseStack.removePromise(thisPromise);
14451450
if (isMostRecent) {
1446-
response.text().then((html) => {
1447-
this._processRerender(html, response, reRenderPromise.unsyncedInputContainer);
1448-
});
1451+
this._processRerender(html, response, reRenderPromise.unsyncedInputContainer);
14491452
}
14501453
});
14511454
}
@@ -1832,6 +1835,48 @@ class default_1 extends Controller {
18321835
clearInterval(interval);
18331836
});
18341837
}
1838+
async renderError(html) {
1839+
let modal = document.getElementById('live-component-error');
1840+
if (modal) {
1841+
modal.innerHTML = '';
1842+
}
1843+
else {
1844+
modal = document.createElement('div');
1845+
modal.id = 'live-component-error';
1846+
modal.style.padding = '50px';
1847+
modal.style.backgroundColor = 'rgba(0, 0, 0, .5)';
1848+
modal.style.zIndex = '100000';
1849+
modal.style.position = 'fixed';
1850+
modal.style.width = '100vw';
1851+
modal.style.height = '100vh';
1852+
}
1853+
const iframe = document.createElement('iframe');
1854+
iframe.style.borderRadius = '5px';
1855+
iframe.style.width = '100%';
1856+
iframe.style.height = '100%';
1857+
modal.appendChild(iframe);
1858+
document.body.prepend(modal);
1859+
document.body.style.overflow = 'hidden';
1860+
if (iframe.contentWindow) {
1861+
iframe.contentWindow.document.open();
1862+
iframe.contentWindow.document.write(html);
1863+
iframe.contentWindow.document.close();
1864+
}
1865+
const closeModal = (modal) => {
1866+
if (modal) {
1867+
modal.outerHTML = '';
1868+
}
1869+
document.body.style.overflow = 'visible';
1870+
};
1871+
modal.addEventListener('click', () => closeModal(modal));
1872+
modal.setAttribute('tabindex', '0');
1873+
modal.addEventListener('keydown', e => {
1874+
if (e.key === 'Escape') {
1875+
closeModal(modal);
1876+
}
1877+
});
1878+
modal.focus();
1879+
}
18351880
}
18361881
default_1.values = {
18371882
url: String,

src/LiveComponent/assets/src/live_controller.ts

+60-4
Original file line numberDiff line numberDiff line change
@@ -459,21 +459,27 @@ export default class extends Controller implements LiveController {
459459
const thisPromise = fetch(`${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, fetchOptions);
460460
const reRenderPromise = new ReRenderPromise(thisPromise, this.unsyncedInputs.clone());
461461
this.renderPromiseStack.addPromise(reRenderPromise);
462-
thisPromise.then((response) => {
462+
thisPromise.then(async (response) => {
463463
if (action) {
464464
this.isActionProcessing = false;
465465
}
466466

467+
// if the response does not contain a component, render as an error
468+
const html = await response.text();
469+
if (response.headers.get('Content-Type') !== 'application/vnd.live-component+html') {
470+
this.renderError(html);
471+
472+
return;
473+
}
474+
467475
// if another re-render is scheduled, do not "run it over"
468476
if (this.renderDebounceTimeout) {
469477
return;
470478
}
471479

472480
const isMostRecent = this.renderPromiseStack.removePromise(thisPromise);
473481
if (isMostRecent) {
474-
response.text().then((html) => {
475-
this._processRerender(html, response, reRenderPromise.unsyncedInputContainer);
476-
});
482+
this._processRerender(html, response, reRenderPromise.unsyncedInputContainer);
477483
}
478484
})
479485
}
@@ -1038,6 +1044,56 @@ export default class extends Controller implements LiveController {
10381044
clearInterval(interval);
10391045
});
10401046
}
1047+
1048+
// inspired by Livewire!
1049+
private async renderError(html: string) {
1050+
let modal = document.getElementById('live-component-error');
1051+
if (modal) {
1052+
modal.innerHTML = '';
1053+
} else {
1054+
modal = document.createElement('div');
1055+
modal.id = 'live-component-error';
1056+
modal.style.padding = '50px';
1057+
modal.style.backgroundColor = 'rgba(0, 0, 0, .5)';
1058+
modal.style.zIndex = '100000';
1059+
modal.style.position = 'fixed';
1060+
modal.style.width = '100vw';
1061+
modal.style.height = '100vh';
1062+
}
1063+
1064+
const iframe = document.createElement('iframe');
1065+
iframe.style.borderRadius = '5px';
1066+
iframe.style.width = '100%';
1067+
iframe.style.height = '100%';
1068+
modal.appendChild(iframe);
1069+
1070+
document.body.prepend(modal);
1071+
document.body.style.overflow = 'hidden';
1072+
if (iframe.contentWindow) {
1073+
iframe.contentWindow.document.open();
1074+
iframe.contentWindow.document.write(html);
1075+
iframe.contentWindow.document.close();
1076+
}
1077+
1078+
const closeModal = (modal: HTMLElement|null) => {
1079+
if (modal) {
1080+
modal.outerHTML = ''
1081+
}
1082+
document.body.style.overflow = 'visible'
1083+
}
1084+
1085+
// close on click
1086+
modal.addEventListener('click', () => closeModal(modal));
1087+
1088+
// close on escape
1089+
modal.setAttribute('tabindex', '0');
1090+
modal.addEventListener('keydown', e => {
1091+
if (e.key === 'Escape') {
1092+
closeModal(modal);
1093+
}
1094+
});
1095+
modal.focus();
1096+
}
10411097
}
10421098

10431099
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* This file is part of the Symfony package.
3+
*
4+
* (c) Fabien Potencier <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
'use strict';
11+
12+
import { createTest, initComponent, shutdownTest } from '../tools';
13+
import { getByText, waitFor } from '@testing-library/dom';
14+
15+
describe('LiveController Error Handling', () => {
16+
afterEach(() => {
17+
shutdownTest();
18+
})
19+
20+
it('displays an error modal on 500 errors', async () => {
21+
const test = await createTest({ }, (data: any) => `
22+
<div ${initComponent(data)}>
23+
Original component text
24+
<button data-action="live#action" data-action-name="save">Save</button>
25+
</div>
26+
`);
27+
28+
// ONLY a post is sent, not a re-render GET
29+
test.expectsAjaxCall('post')
30+
.expectSentData(test.initialData)
31+
.serverWillReturnCustomResponse(500, `
32+
<html><head><title>Error!</title></head><body><h1>An error occurred</h1></body></html>
33+
`)
34+
.expectActionCalled('save')
35+
.init();
36+
37+
getByText(test.element, 'Save').click();
38+
39+
await waitFor(() => expect(document.getElementById('live-component-error')).not.toBeNull());
40+
// the component did not change or re-render
41+
expect(test.element).toHaveTextContent('Original component text');
42+
const errorContainer = document.getElementById('live-component-error');
43+
if (!errorContainer) {
44+
throw new Error('containing missing');
45+
}
46+
expect(errorContainer.querySelector('iframe')).not.toBeNull();
47+
});
48+
49+
it('displays a modal on any non-component response', async () => {
50+
const test = await createTest({ }, (data: any) => `
51+
<div ${initComponent(data)}>
52+
Original component text
53+
<button data-action="live#action" data-action-name="save">Save</button>
54+
</div>
55+
`);
56+
57+
// ONLY a post is sent, not a re-render GET
58+
test.expectsAjaxCall('post')
59+
.expectSentData(test.initialData)
60+
.serverWillReturnCustomResponse(200, `
61+
<html><head><title>Hi!</title></head><body><h1>I'm a whole page, not a component!</h1></body></html>
62+
`)
63+
.expectActionCalled('save')
64+
.init();
65+
66+
getByText(test.element, 'Save').click();
67+
68+
await waitFor(() => expect(document.getElementById('live-component-error')).not.toBeNull());
69+
// the component did not change or re-render
70+
expect(test.element).toHaveTextContent('Original component text');
71+
});
72+
});

src/LiveComponent/assets/test/tools.ts

+26-1
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ class MockedAjaxCall {
120120
options: any = {};
121121
fetchMock?: typeof fetchMock;
122122
routeName?: string;
123+
customResponseStatusCode?: number;
124+
customResponseHTML?: string;
123125

124126
constructor(method: string, test: FunctionalTest) {
125127
this.method = method.toUpperCase();
@@ -180,9 +182,24 @@ class MockedAjaxCall {
180182
// use custom template, or the main one
181183
const template = this.template ? this.template : this.test.template;
182184

185+
let response;
186+
if (this.customResponseStatusCode) {
187+
response = {
188+
body: this.customResponseHTML,
189+
status: this.customResponseStatusCode
190+
}
191+
} else {
192+
response = {
193+
body: template(finalServerData),
194+
headers: {
195+
'Content-Type': 'application/vnd.live-component+html'
196+
}
197+
}
198+
}
199+
183200
this.fetchMock = fetchMock.mock(
184201
this.getMockMatcher(),
185-
template(finalServerData),
202+
response,
186203
this.options
187204
);
188205
}
@@ -201,6 +218,14 @@ class MockedAjaxCall {
201218
return this;
202219
}
203220

221+
serverWillReturnCustomResponse(statusCode: number, responseHTML: string): MockedAjaxCall {
222+
this.checkInitialization('serverWillReturnAnError');
223+
this.customResponseStatusCode = statusCode;
224+
this.customResponseHTML = responseHTML;
225+
226+
return this;
227+
}
228+
204229
getVisualSummary(): string {
205230
const requestInfo = [];
206231
requestInfo.push(` METHOD: ${this.method}`);

src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php

+9
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
<?php
22

3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
312
namespace Symfony\UX\LiveComponent\EventListener;
413

514
use Psr\Container\ContainerInterface;

src/LiveComponent/src/EventListener/LiveComponentSubscriber.php

+3-1
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,9 @@ private function createResponse(MountedComponent $mounted): Response
232232
$component->{$method->name}();
233233
}
234234

235-
return new Response($this->container->get(ComponentRenderer::class)->render($mounted));
235+
return new Response($this->container->get(ComponentRenderer::class)->render($mounted), 200, [
236+
'Content-Type' => self::HTML_CONTENT_TYPE,
237+
]);
236238
}
237239

238240
private function isLiveComponentRequest(Request $request): bool

src/LiveComponent/src/LiveComponentHydrator.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ private function verifyChecksum(array $data, array $readonlyProperties): void
235235
}
236236

237237
if (!hash_equals($this->computeChecksum($data, $readonlyProperties), $data[self::CHECKSUM_KEY])) {
238-
throw new UnprocessableEntityHttpException('Invalid checksum!');
238+
throw new UnprocessableEntityHttpException('Invalid checksum. This usually means that you tried to change a property that is not writable: true.');
239239
}
240240
}
241241

0 commit comments

Comments
 (0)