Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/LiveComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 3.5

- Add `LiveResponse::downloadUrl()` and `LiveResponse::downloadFile()` to trigger a file download from a `LiveAction`, pointing the browser at a URL or sending the contents with the response, while the component keeps its state
- Add `LiveResponse::remove()` to take a component off the page from a `LiveAction`, instead of re-rendering it: the root element is removed and the Stimulus controller disconnects, and the server skips the render entirely

## 3.1

Expand Down
3 changes: 3 additions & 0 deletions src/LiveComponent/assets/dist/live_controller.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ declare class export_default$2 {
getDownload(): Download | null;
getLiveUrl(): string | null;
getDownloadUrl(): string | null;
isRemoved(): boolean;
private parse;
}
declare class export_default$1 {
Expand Down Expand Up @@ -109,6 +110,7 @@ declare class Component {
private pendingActions;
private pendingFiles;
private isRequestPending;
private isRemoved;
private requestDebounceTimeout;
private nextRequestPromise;
private nextRequestPromiseResolve;
Expand All @@ -134,6 +136,7 @@ declare class Component {
private performEmit;
private doEmit;
private isTurboEnabled;
private removeFromPage;
private tryStartingRequest;
private performRequest;
private processRerender;
Expand Down
28 changes: 27 additions & 1 deletion src/LiveComponent/assets/dist/live_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ var BackendResponse_default = class {
getDownloadUrl() {
return this.response.headers.get("X-Live-Download-Url");
}
isRemoved() {
return this.response.headers.has("X-Live-Remove");
}
async parse() {
const htmlLength = this.response.headers.get("X-Live-Html-Length");
if (null === htmlLength) {
Expand Down Expand Up @@ -1411,6 +1414,7 @@ var Component = class {
this.pendingActions = [];
this.pendingFiles = {};
this.isRequestPending = false;
this.isRemoved = false;
this.requestDebounceTimeout = null;
this.element = element;
this.name = name;
Expand Down Expand Up @@ -1509,7 +1513,21 @@ var Component = class {
isTurboEnabled() {
return typeof Turbo !== "undefined" && !this.element.closest("[data-turbo=\"false\"]");
}
removeFromPage() {
this.isRemoved = true;
this.disconnect();
const element = this.element;
for (const name of element.getAttributeNames()) if (name.startsWith("data-live-") && name.endsWith("-value")) element.removeAttribute(name);
element.setAttribute("data-live-removing", "");
requestAnimationFrame(() => {
const animations = (element.getAnimations?.({ subtree: true }) ?? []).filter((animation) => animation.effect?.getComputedTiming().endTime !== Number.POSITIVE_INFINITY);
Promise.allSettled(animations.map((animation) => animation.finished)).then(() => {
element.remove();
});
});
}
tryStartingRequest() {
if (this.isRemoved) return;
if (!this.backendRequest) {
this.performRequest();
return;
Expand Down Expand Up @@ -1543,7 +1561,7 @@ var Component = class {
const headers = backendResponse.response.headers;
for (const input of Object.values(this.pendingFiles)) input.value = "";
const html = await backendResponse.getBody();
if (!headers.get("Content-Type")?.includes("application/vnd.live-component+html") && !headers.get("X-Live-Redirect")) {
if (!headers.get("Content-Type")?.includes("application/vnd.live-component+html") && !headers.get("X-Live-Redirect") && !headers.has("X-Live-Remove")) {
const controls = { displayError: true };
this.valueStore.pushPendingPropsBackToDirty();
this.hooks.triggerHook("response:error", backendResponse, controls);
Expand All @@ -1552,6 +1570,14 @@ var Component = class {
thisPromiseResolve(backendResponse);
return response;
}
if (backendResponse.isRemoved()) {
this.isRemoved = true;
this.processRerender(html, backendResponse);
this.backendRequest = null;
thisPromiseResolve(backendResponse);
this.removeFromPage();
return response;
}
const liveUrl = backendResponse.getLiveUrl();
if (liveUrl) history.replaceState(history.state, "", new URL(liveUrl + window.location.hash, window.location.origin));
this.processRerender(html, backendResponse);
Expand Down
7 changes: 7 additions & 0 deletions src/LiveComponent/assets/src/Backend/BackendResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ export default class {
return this.response.headers.get('X-Live-Download-Url');
}

/**
* Whether the component is meant to leave the page after its final re-render.
*/
isRemoved(): boolean {
return this.response.headers.has('X-Live-Remove');
}

private async parse(): Promise<void> {
const htmlLength = this.response.headers.get('X-Live-Html-Length');

Expand Down
66 changes: 65 additions & 1 deletion src/LiveComponent/assets/src/Component/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export default class Component {
private pendingFiles: { [key: string]: HTMLInputElement } = {};
/** Is a request waiting to be made? */
private isRequestPending = false;
/** Once removed, the component is done: it must never talk to the server again. */
private isRemoved = false;
/** Current "timeout" before the pending request should be sent. */
private requestDebounceTimeout: number | null = null;
private nextRequestPromise: Promise<BackendResponse>;
Expand Down Expand Up @@ -251,7 +253,53 @@ export default class Component {
return typeof Turbo !== 'undefined' && !this.element.closest('[data-turbo="false"]');
}

/**
* Ends the component on the page.
*
* Once the final render and its events have been processed, polling stops and the
* component leaves the registry. The element is then marked with `data-live-removing`
* and left in place, so the page can animate it out without a live component still
* answering for it.
*
* With no animation on `[data-live-removing]`, there is nothing to wait for and the
* element goes on the next frame.
*/
private removeFromPage(): void {
this.isRemoved = true;
this.disconnect();

const element = this.element;

// the props are what makes this element a live component: dropping them keeps
// anything from re-hydrating it, here or on a later render
for (const name of element.getAttributeNames()) {
if (name.startsWith('data-live-') && name.endsWith('-value')) {
element.removeAttribute(name);
}
}

element.setAttribute('data-live-removing', '');

// a transition only exists once the attribute has been applied, so give the browser
// a frame to create it before asking what is running
requestAnimationFrame(() => {
const animations = (element.getAnimations?.({ subtree: true }) ?? [])
// an endless animation would keep the element on the page forever
.filter((animation) => animation.effect?.getComputedTiming().endTime !== Number.POSITIVE_INFINITY);

Promise.allSettled(animations.map((animation) => animation.finished)).then(() => {
element.remove();
});
});
}

private tryStartingRequest(): void {
if (this.isRemoved) {
// the element may still be on the page while it animates out, and it keeps its
// listeners until then: a click must not reach a component that is already gone
return;
}

if (!this.backendRequest) {
this.performRequest();

Expand Down Expand Up @@ -321,7 +369,8 @@ export default class Component {
// if the response does not contain a component, render as an error
if (
!headers.get('Content-Type')?.includes('application/vnd.live-component+html') &&
!headers.get('X-Live-Redirect')
!headers.get('X-Live-Redirect') &&
!headers.has('X-Live-Remove')
) {
const controls = { displayError: true };
this.valueStore.pushPendingPropsBackToDirty();
Expand All @@ -337,6 +386,21 @@ export default class Component {
return response;
}

// The render carries the usual LiveComponent instructions. Make this component
// terminal before processing them, so a synchronous event handler cannot start
// another request on the component that is about to leave.
if (backendResponse.isRemoved()) {
this.isRemoved = true;
this.processRerender(html, backendResponse);

this.backendRequest = null;
thisPromiseResolve(backendResponse);

this.removeFromPage();

return response;
}

const liveUrl = backendResponse.getLiveUrl();
if (liveUrl) {
history.replaceState(
Expand Down
14 changes: 14 additions & 0 deletions src/LiveComponent/assets/test/unit/Backend/BackendResponse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,4 +179,18 @@ describe('BackendResponse', () => {
expect(makeResponse().getLiveUrl()).toBeNull();
});
});

describe('isRemoved()', () => {
it('is true when the X-Live-Remove header is present', () => {
expect(makeResponse({ 'X-Live-Remove': '1' }).isRemoved()).toBe(true);
});

it('is false when absent', () => {
expect(makeResponse().isRemoved()).toBe(false);
});

it('only looks at the presence of the header, not its value', () => {
expect(makeResponse({ 'X-Live-Remove': '0' }).isRemoved()).toBe(true);
});
});
});
Loading
Loading