Skip to content

Commit 2ba9b88

Browse files
committed
Support JSONResponse in LiveActions
1 parent cffc046 commit 2ba9b88

File tree

7 files changed

+349
-31
lines changed

7 files changed

+349
-31
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## 2.28.0
44

5+
- Added `JSONResponse` support (LiveAction functions) in LiveComponent.
6+
7+
## 2.28.0
8+
59
- Add new modifiers for input validations, useful to prevent unnecessary HTTP requests:
610
- `min_length` and `max_length`: validate length from textual input elements
711
- `min_value` and `max_value`: validate value from numeral input elements

src/LiveComponent/assets/dist/live_controller.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ declare class export_default$2{
77
constructor(response: Response);
88
getBody(): Promise<string>;
99
getLiveUrl(): string | null;
10+
checkResponseType(): Promise<{
11+
type: 'json' | 'html' | 'invalid';
12+
body: string;
13+
}>;
1014
}
1115

1216
declare class export_default$1{
@@ -81,6 +85,9 @@ type ComponentHooks = {
8185
connect: (component: Component) => MaybePromise;
8286
disconnect: (component: Component) => MaybePromise;
8387
'request:started': (requestConfig: any) => MaybePromise;
88+
'render:started': (backendResponseBody: string, backendResponse: export_default$2, controls: {
89+
shouldRender: boolean;
90+
}) => MaybePromise;
8491
'render:finished': (component: Component) => MaybePromise;
8592
'response:error': (backendResponse: export_default$2, controls: {
8693
displayError: boolean;

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,24 @@ var BackendResponse_default = class {
143143
}
144144
return this.liveUrl;
145145
}
146+
async checkResponseType() {
147+
const contentType = this.response.headers.get("Content-Type") || "";
148+
const headers = this.response.headers;
149+
const text = await this.getBody();
150+
const trimmed = text.trim();
151+
if (contentType.includes("application/json")) {
152+
try {
153+
JSON.parse(trimmed);
154+
return { type: "json", body: trimmed };
155+
} catch {
156+
}
157+
}
158+
const isValidHtml = trimmed.length > 0 && (contentType.includes("application/vnd.live-component+html") || headers.get("X-Live-Redirect") !== null);
159+
if (isValidHtml) {
160+
return { type: "html", body: trimmed };
161+
}
162+
return { type: "invalid", body: trimmed };
163+
}
146164
};
147165

148166
// src/Util/getElementAsTagText.ts
@@ -2005,23 +2023,32 @@ var Component = class {
20052023
this.isRequestPending = false;
20062024
this.backendRequest.promise.then(async (response) => {
20072025
const backendResponse = new BackendResponse_default(response);
2008-
const html = await backendResponse.getBody();
2026+
const result = await backendResponse.checkResponseType();
2027+
if (result.type === "json") {
2028+
this.backendRequest = null;
2029+
thisPromiseResolve(backendResponse);
2030+
if (this.isRequestPending) {
2031+
this.isRequestPending = false;
2032+
this.performRequest();
2033+
}
2034+
return response;
2035+
}
20092036
for (const input of Object.values(this.pendingFiles)) {
20102037
input.value = "";
20112038
}
2012-
const headers = backendResponse.response.headers;
2013-
if (!headers.get("Content-Type")?.includes("application/vnd.live-component+html") && !headers.get("X-Live-Redirect")) {
2039+
const backendResponseBody = result.body;
2040+
if (result.type === "invalid") {
20142041
const controls = { displayError: true };
20152042
this.valueStore.pushPendingPropsBackToDirty();
20162043
this.hooks.triggerHook("response:error", backendResponse, controls);
2017-
if (controls.displayError && !headers.get("Content-Type")?.includes("application/json")) {
2018-
this.renderError(html);
2044+
if (controls.displayError) {
2045+
this.renderError(backendResponseBody);
20192046
}
20202047
this.backendRequest = null;
20212048
thisPromiseResolve(backendResponse);
20222049
return response;
20232050
}
2024-
this.processRerender(html, backendResponse);
2051+
this.processRerender(backendResponseBody, backendResponse);
20252052
const liveUrl = backendResponse.getLiveUrl();
20262053
if (liveUrl) {
20272054
history.replaceState(
@@ -2039,9 +2066,9 @@ var Component = class {
20392066
return response;
20402067
});
20412068
}
2042-
processRerender(html, backendResponse) {
2069+
processRerender(backendResponseBody, backendResponse) {
20432070
const controls = { shouldRender: true };
2044-
this.hooks.triggerHook("render:started", html, backendResponse, controls);
2071+
this.hooks.triggerHook("render:started", backendResponseBody, backendResponse, controls);
20452072
if (!controls.shouldRender) {
20462073
return;
20472074
}
@@ -2060,7 +2087,7 @@ var Component = class {
20602087
});
20612088
let newElement;
20622089
try {
2063-
newElement = htmlToElement(html);
2090+
newElement = htmlToElement(backendResponseBody);
20642091
if (!newElement.matches("[data-controller~=live]")) {
20652092
throw new Error("A live component template must contain a single root controller element.");
20662093
}
@@ -2130,7 +2157,7 @@ var Component = class {
21302157
}, this.calculateDebounce(debounce));
21312158
}
21322159
// inspired by Livewire!
2133-
renderError(html) {
2160+
renderError(backendResponseBody) {
21342161
let modal = document.getElementById("live-component-error");
21352162
if (modal) {
21362163
modal.innerHTML = "";
@@ -2156,7 +2183,7 @@ var Component = class {
21562183
document.body.style.overflow = "hidden";
21572184
if (iframe.contentWindow) {
21582185
iframe.contentWindow.document.open();
2159-
iframe.contentWindow.document.write(html);
2186+
iframe.contentWindow.document.write(backendResponseBody);
21602187
iframe.contentWindow.document.close();
21612188
}
21622189
const closeModal = (modal2) => {

src/LiveComponent/assets/src/Backend/BackendResponse.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,31 @@ export default class {
2222

2323
return this.liveUrl;
2424
}
25+
26+
async checkResponseType(): Promise<{ type: 'json' | 'html' | 'invalid'; body: string }> {
27+
const contentType = this.response.headers.get('Content-Type') || '';
28+
const headers = this.response.headers;
29+
30+
const text = await this.getBody();
31+
const trimmed = text.trim();
32+
33+
if (contentType.includes('application/json')) {
34+
try {
35+
JSON.parse(trimmed);
36+
return { type: 'json', body: trimmed };
37+
} catch {
38+
// not valid JSON
39+
}
40+
}
41+
42+
const isValidHtml =
43+
trimmed.length > 0 &&
44+
(contentType.includes('application/vnd.live-component+html') || headers.get('X-Live-Redirect') !== null);
45+
46+
if (isValidHtml) {
47+
return { type: 'html', body: trimmed };
48+
}
49+
50+
return { type: 'invalid', body: trimmed };
51+
}
2552
}

src/LiveComponent/assets/src/Component/index.ts

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ export type ComponentHooks = {
2020
connect: (component: Component) => MaybePromise;
2121
disconnect: (component: Component) => MaybePromise;
2222
'request:started': (requestConfig: any) => MaybePromise;
23+
'render:started': (
24+
backendResponseBody: string,
25+
backendResponse: BackendResponse,
26+
controls: { shouldRender: boolean }
27+
) => MaybePromise;
2328
'render:finished': (component: Component) => MaybePromise;
2429
'response:error': (backendResponse: BackendResponse, controls: { displayError: boolean }) => MaybePromise;
2530
'loading.state:started': (element: HTMLElement, request: BackendRequest) => MaybePromise;
@@ -300,34 +305,43 @@ export default class Component {
300305

301306
this.backendRequest.promise.then(async (response) => {
302307
const backendResponse = new BackendResponse(response);
303-
const html = await backendResponse.getBody();
308+
const result = await backendResponse.checkResponseType();
304309

305-
// clear sent files inputs
310+
if (result.type === 'json') {
311+
this.backendRequest = null;
312+
thisPromiseResolve(backendResponse);
313+
314+
if (this.isRequestPending) {
315+
this.isRequestPending = false;
316+
this.performRequest();
317+
}
318+
return response;
319+
}
320+
321+
// Clear all file inputs
306322
for (const input of Object.values(this.pendingFiles)) {
307323
input.value = '';
308324
}
309325

326+
const backendResponseBody = result.body;
327+
310328
// if the response does not contain a component, render as an error
311-
const headers = backendResponse.response.headers;
312-
if (
313-
!headers.get('Content-Type')?.includes('application/vnd.live-component+html') &&
314-
!headers.get('X-Live-Redirect')
315-
) {
329+
if (result.type === 'invalid') {
316330
const controls = { displayError: true };
317331
this.valueStore.pushPendingPropsBackToDirty();
318332
this.hooks.triggerHook('response:error', backendResponse, controls);
319333

320-
if (controls.displayError && !headers.get('Content-Type')?.includes('application/json')) {
321-
this.renderError(html);
334+
if (controls.displayError) {
335+
this.renderError(backendResponseBody);
322336
}
323337

324338
this.backendRequest = null;
325339
thisPromiseResolve(backendResponse);
326-
327340
return response;
328341
}
329342

330-
this.processRerender(html, backendResponse);
343+
// HTML processing
344+
this.processRerender(backendResponseBody, backendResponse);
331345
const liveUrl = backendResponse.getLiveUrl();
332346
if (liveUrl) {
333347
history.replaceState(
@@ -337,11 +351,9 @@ export default class Component {
337351
);
338352
}
339353

340-
// finally resolve this promise
341354
this.backendRequest = null;
342355
thisPromiseResolve(backendResponse);
343356

344-
// do we already have another request pending?
345357
if (this.isRequestPending) {
346358
this.isRequestPending = false;
347359
this.performRequest();
@@ -351,9 +363,9 @@ export default class Component {
351363
});
352364
}
353365

354-
private processRerender(html: string, backendResponse: BackendResponse) {
366+
private processRerender(backendResponseBody: string, backendResponse: BackendResponse) {
355367
const controls = { shouldRender: true };
356-
this.hooks.triggerHook('render:started', html, backendResponse, controls);
368+
this.hooks.triggerHook('render:started', backendResponseBody, backendResponse, controls);
357369
// used to notify that the component doesn't live on the page anymore
358370
if (!controls.shouldRender) {
359371
return;
@@ -387,7 +399,7 @@ export default class Component {
387399

388400
let newElement: HTMLElement;
389401
try {
390-
newElement = htmlToElement(html);
402+
newElement = htmlToElement(backendResponseBody);
391403

392404
if (!newElement.matches('[data-controller~=live]')) {
393405
throw new Error('A live component template must contain a single root controller element.');
@@ -477,7 +489,7 @@ export default class Component {
477489
}
478490

479491
// inspired by Livewire!
480-
private renderError(html: string): void {
492+
private renderError(backendResponseBody: string): void {
481493
let modal = document.getElementById('live-component-error');
482494
if (modal) {
483495
modal.innerHTML = '';
@@ -505,7 +517,7 @@ export default class Component {
505517
document.body.style.overflow = 'hidden';
506518
if (iframe.contentWindow) {
507519
iframe.contentWindow.document.open();
508-
iframe.contentWindow.document.write(html);
520+
iframe.contentWindow.document.write(backendResponseBody);
509521
iframe.contentWindow.document.close();
510522
}
511523

0 commit comments

Comments
 (0)