diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 039f9e8450e..39c9155104b 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -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 diff --git a/src/LiveComponent/assets/dist/live_controller.d.ts b/src/LiveComponent/assets/dist/live_controller.d.ts index dbb9d60ad27..d5b05570fab 100644 --- a/src/LiveComponent/assets/dist/live_controller.d.ts +++ b/src/LiveComponent/assets/dist/live_controller.d.ts @@ -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 { @@ -109,6 +110,7 @@ declare class Component { private pendingActions; private pendingFiles; private isRequestPending; + private isRemoved; private requestDebounceTimeout; private nextRequestPromise; private nextRequestPromiseResolve; @@ -134,6 +136,7 @@ declare class Component { private performEmit; private doEmit; private isTurboEnabled; + private removeFromPage; private tryStartingRequest; private performRequest; private processRerender; diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index afd94dac287..29504b3bb29 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -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) { @@ -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; @@ -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; @@ -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); @@ -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); diff --git a/src/LiveComponent/assets/src/Backend/BackendResponse.ts b/src/LiveComponent/assets/src/Backend/BackendResponse.ts index b3629ce6870..dfe7b9974a3 100644 --- a/src/LiveComponent/assets/src/Backend/BackendResponse.ts +++ b/src/LiveComponent/assets/src/Backend/BackendResponse.ts @@ -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 { const htmlLength = this.response.headers.get('X-Live-Html-Length'); diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index ebb5fc273fa..d1545df9cbd 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -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; @@ -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(); @@ -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(); @@ -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( diff --git a/src/LiveComponent/assets/test/unit/Backend/BackendResponse.test.ts b/src/LiveComponent/assets/test/unit/Backend/BackendResponse.test.ts index b46486c82b1..9ee14042540 100644 --- a/src/LiveComponent/assets/test/unit/Backend/BackendResponse.test.ts +++ b/src/LiveComponent/assets/test/unit/Backend/BackendResponse.test.ts @@ -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); + }); + }); }); diff --git a/src/LiveComponent/assets/test/unit/Component/index.test.ts b/src/LiveComponent/assets/test/unit/Component/index.test.ts index a5a9756e580..ed14805a507 100644 --- a/src/LiveComponent/assets/test/unit/Component/index.test.ts +++ b/src/LiveComponent/assets/test/unit/Component/index.test.ts @@ -274,6 +274,223 @@ describe('Component class', () => { }); }); + describe('component removal', () => { + // the noop driver throws on every method: a request needs one that answers + class renderingDriver extends noopElementDriver { + constructor( + private eventsToEmit: Array = [], + private browserEventsToDispatch: Array = [] + ) { + super(); + } + + getComponentProps(): any { + return {}; + } + getEventsToEmit(): Array { + return this.eventsToEmit; + } + getBrowserEventsToDispatch(): Array { + return this.browserEventsToDispatch; + } + } + + /** + * A removal carries one final LiveComponent render. X-Live-Remove tells the client + * to process it and then take the component off the page. + */ + const makeRemovableComponent = ( + headers: Record = { + 'Content-Type': 'application/vnd.live-component+html', + 'X-Live-Remove': '1', + }, + body = '
rendered
', + eventsToEmit: Array = [], + browserEventsToDispatch: Array = [] + ): Component => { + const backend: MockBackend = { + actions: [], + makeRequest(_data: any, actions: BackendAction[]): BackendRequest { + this.actions = actions; + + return new BackendRequest( + new Promise((resolve) => + resolve( + // @ts-expect-error Response doesn't quite match the underlying interface + new Response(body, { status: 200, headers }) + ) + ), + [], + [] + ); + }, + }; + + const element = document.createElement('div'); + document.body.appendChild(element); + + return new Component( + element, + 'test-component', + { firstName: '' }, + [], + null, + backend, + new renderingDriver(eventsToEmit, browserEventsToDispatch) + ); + }; + + /** The element is only dropped a frame later, once its animations have settled. */ + const nextFrame = (): Promise => new Promise((resolve) => requestAnimationFrame(() => resolve())); + + it('takes the element off the page', async () => { + const component = makeRemovableComponent(); + + expect(component.element.isConnected).toBe(true); + + await component.set('firstName', 'Kevin', true); + await nextFrame(); + await nextFrame(); + + expect(component.element.isConnected).toBe(false); + }); + + it('marks the element as leaving, so the page can animate it out', async () => { + const component = makeRemovableComponent(); + + await component.set('firstName', 'Kevin', true); + + // still there, but no longer a live component + expect(component.element.isConnected).toBe(true); + expect(component.element.hasAttribute('data-live-removing')).toBe(true); + }); + + it('strips the props, so nothing can re-hydrate the element', async () => { + const component = makeRemovableComponent(); + component.element.setAttribute('data-live-props-value', '{"firstName":"Kevin"}'); + component.element.setAttribute('data-live-url-value', '/_components/foo'); + + await component.set('firstName', 'Kevin', true); + + expect(component.element.hasAttribute('data-live-props-value')).toBe(false); + expect(component.element.hasAttribute('data-live-url-value')).toBe(false); + }); + + it('never talks to the server again, even while it is still on the page', async () => { + const component = makeRemovableComponent(); + const makeRequest = vi.spyOn(component.backend, 'makeRequest'); + + await component.set('firstName', 'Kevin', true); + makeRequest.mockClear(); + + // render() reaches the request funnel synchronously, unlike the debounced + // action() path: the element keeps its listeners until it goes, so a click + // must not reach a component that is already gone + component.render(); + + expect(makeRequest).not.toHaveBeenCalled(); + }); + + it('would otherwise have talked to the server', async () => { + // the counter-proof: without a removal, the very same call does reach the backend + const component = makeRemovableComponent( + { 'Content-Type': 'application/vnd.live-component+html' }, + '
rendered
' + ); + const makeRequest = vi.spyOn(component.backend, 'makeRequest'); + + await component.set('firstName', 'Kevin', true); + makeRequest.mockClear(); + + component.render(); + + expect(makeRequest).toHaveBeenCalled(); + }); + + it('processes the final render before removing the component', async () => { + const component = makeRemovableComponent(); + const renderStarted = vi.fn(); + component.on('render:started', renderStarted); + + await component.set('firstName', 'Kevin', true); + + expect(renderStarted).toHaveBeenCalledOnce(); + expect(component.element).toHaveTextContent('rendered'); + }); + + it('emits LiveComponent events before disconnecting', async () => { + const component = makeRemovableComponent(undefined, undefined, [ + { event: 'componentRemoved', data: { id: 42 }, target: null, componentName: null }, + ]); + const listener = new Component( + document.createElement('div'), + 'listener-component', + {}, + [{ event: 'componentRemoved', action: 'refresh' }], + null, + component.backend, + new renderingDriver() + ); + const action = vi.spyOn(listener, 'action'); + component.connect(); + listener.connect(); + + try { + await component.set('firstName', 'Kevin', true); + + expect(action).toHaveBeenCalledWith('refresh', { id: 42 }, 1); + } finally { + listener.disconnect(); + } + }); + + it('dispatches browser events before disconnecting', async () => { + const component = makeRemovableComponent( + undefined, + undefined, + [], + [{ event: 'component:removed', payload: { id: 42 } }] + ); + const received: Array = []; + const makeRequest = vi.spyOn(component.backend, 'makeRequest'); + let disconnected = false; + component.on('disconnect', () => { + disconnected = true; + }); + component.element.addEventListener('component:removed', (event) => { + received.push({ detail: (event as CustomEvent).detail, disconnected }); + component.render(); + }); + + await component.set('firstName', 'Kevin', true); + + expect(received).toEqual([{ detail: { id: 42 }, disconnected: false }]); + expect(makeRequest).toHaveBeenCalledOnce(); + expect(disconnected).toBe(true); + }); + + it('is not mistaken for a response the client cannot use', async () => { + const component = makeRemovableComponent(); + const responseError = vi.fn(); + component.on('response:error', responseError); + + await component.set('firstName', 'Kevin', true); + + expect(responseError).not.toHaveBeenCalled(); + }); + + it('leaves the element alone without the header', async () => { + const component = makeRemovableComponent( + { 'Content-Type': 'application/vnd.live-component+html' }, + '
rendered
' + ); + + await component.set('firstName', 'Kevin', true); + + expect(component.element.isConnected).toBe(true); + }); + }); + describe('Proxy wrapper', () => { const makeDummyComponent = (): { proxy: Component; backend: MockBackend } => { const { backend, component } = makeTestComponent(); diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index fad48283dcf..5c817e67222 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -16,6 +16,7 @@ A real-time product search component might look like this:: use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveProp; + use Symfony\UX\LiveComponent\ComponentToolsTrait; use Symfony\UX\LiveComponent\DefaultActionTrait; #[AsLiveComponent] @@ -1336,6 +1337,93 @@ the component now extends ``AbstractController``! That is totally allowed, and gives you access to all of your normal controller shortcuts. We even added a flash message! +.. _removing-a-component: + +Removing a Component from the Page +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 3.5 + + ``LiveResponse::remove()`` was added in LiveComponent 3.5. + +An action can end the component after one final re-render:: + + // src/Twig/Components/NotificationBanner.php + namespace App\Twig\Components; + + use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; + use Symfony\UX\LiveComponent\Attribute\LiveAction; + use Symfony\UX\LiveComponent\Attribute\LiveProp; + use Symfony\UX\LiveComponent\DefaultActionTrait; + use Symfony\UX\LiveComponent\LiveResponse; + + #[AsLiveComponent] + class NotificationBanner + { + use DefaultActionTrait; + use ComponentToolsTrait; + + #[LiveProp] + public Notification $notification; + + #[LiveAction] + public function dismiss(NotificationRepository $repository): LiveResponse + { + $repository->markAsRead($this->notification); + $this->emit('notificationDismissed', ['id' => $this->notification->getId()]); + + return LiveResponse::remove(); + } + } + +.. code-block:: html+twig + +
+ {{ notification.message }} + + +
+ +The server performs one final render. This carries the usual LiveComponent instructions, +so events emitted by the action reach other components and browser events are dispatched. +The component becomes terminal before those events are processed, so their handlers cannot +start another request on the component being removed. It then disconnects, leaves the +registry, drops its props and is taken off the page. + +Nothing is deleted server-side. This ends the component on the page, and says nothing +about your data. + +Animating the removal +..................... + +On its way out, the element carries a ``data-live-removing`` attribute, and it is only +dropped once whatever you animate on it has finished: + +.. code-block:: css + + .notification { + transition: opacity 300ms, translate 300ms; + } + + .notification[data-live-removing] { + opacity: 0; + translate: 2rem 0; + } + +Nothing to declare beyond the CSS: with no animation on ``[data-live-removing]``, there +is nothing to wait for and the element goes on the next frame. An endless animation is +ignored, as it would keep the element on the page forever. + +The component is already dead by then, so the element that fades out is inert: it polls +nothing, and a click on one of its buttons reaches nobody. + +Use it for something the user dismisses on its own: a flash, a banner, a notification. +When another part of the page has to react, :ref:`emit an event ` before returning +the removal response. + +Like the download responses, ``LiveResponse::remove()`` can only be returned from a +``LiveAction`` or a ``LiveListener``, over POST. + .. _working-with-files: Files diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 62d81c8cb16..51022432def 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -50,6 +50,7 @@ class LiveComponentSubscriber implements EventSubscriberInterface, ServiceSubscr private const DOWNLOAD_FILENAME_HEADER = 'X-Live-Download-Filename'; private const DOWNLOAD_TYPE_HEADER = 'X-Live-Download-Type'; private const DOWNLOAD_URL_HEADER = 'X-Live-Download-Url'; + private const REMOVE_HEADER = 'X-Live-Remove'; public function __construct( private ContainerInterface $container, @@ -288,7 +289,7 @@ public function onKernelView(ViewEvent $event): void private function assertLiveResponseIsAllowed(Request $request): void { if (!$request->isMethod('post')) { - throw new \LogicException('A LiveResponse can only be returned from a POST request. A GET is replayable (prefetch, crawlers), which a download is not.'); + throw new \LogicException('A LiveResponse can only be returned from a POST request. A GET is replayable (prefetch, crawlers), which downloading a file or removing a component is not.'); } if ($request->attributes->get('_component_default_action', false)) { @@ -368,6 +369,15 @@ private function createResponse(MountedComponent $mounted, ?LiveResponse $liveRe $html = $this->container->get(ComponentRenderer::class)->render($mounted); + if ($liveResponse?->isRemove()) { + // the render carries the usual LiveComponent instructions, including emitted + // events; the browser processes them before taking the component off the page + return new Response($html, 200, [ + 'Content-Type' => self::HTML_CONTENT_TYPE, + self::REMOVE_HEADER => '1', + ]); + } + if (null === $liveResponse) { return new Response($html, 200, [ 'Content-Type' => self::HTML_CONTENT_TYPE, diff --git a/src/LiveComponent/src/LiveResponse.php b/src/LiveComponent/src/LiveResponse.php index a24995d7a1d..b6916dd2e6d 100644 --- a/src/LiveComponent/src/LiveResponse.php +++ b/src/LiveComponent/src/LiveResponse.php @@ -14,9 +14,9 @@ /** * Returned from a LiveAction to ask the browser for something the render alone cannot express. * - * This is not an HTTP response: LiveComponentSubscriber turns it into one, alongside the - * re-rendered component. That is the whole point: the component still renders, so whatever - * the action changed is applied on the page even though a file was downloaded. + * This is not an HTTP response: LiveComponentSubscriber turns it into one. A download rides + * alongside the re-rendered component, so whatever the action changed is still applied on the + * page even though a file was downloaded or the component was removed. * * #[LiveAction] * public function export(): LiveResponse @@ -28,7 +28,7 @@ * * Can only be returned from a LiveAction or a LiveListener, over POST. Returning one from the * default action throws, as that action runs on every re-render: a polling component would - * otherwise download a file on every tick. + * otherwise download a file, or remove itself, on every tick. * * @author Simon André * @author Kevin Bond @@ -48,9 +48,32 @@ private function __construct( public readonly ?string $contentType = null, public readonly ?int $size = null, public readonly ?string $url = null, + private readonly bool $remove = false, ) { } + /** + * Takes the component off the page after its final re-render. + * + * The root element is removed and the Stimulus controller disconnects, which tears down + * polling, listeners and registration. Nothing is deleted server-side: what the action did + * to your data is your business, this only ends the component on the page. + * + * The final render delivers the usual LiveComponent events before the component disconnects. + * + * #[LiveAction] + * public function dismiss(): LiveResponse + * { + * $this->notification->markAsRead(); + * + * return LiveResponse::remove(); + * } + */ + public static function remove(): self + { + return new self(remove: true); + } + /** * Points the browser at a URL it downloads itself. * @@ -146,4 +169,14 @@ public function isDownloadUrl(): bool { return null !== $this->url; } + + /** + * Whether the component leaves the page instead of being re-rendered. + * + * @internal + */ + public function isRemove(): bool + { + return $this->remove; + } } diff --git a/src/LiveComponent/tests/Fixtures/Component/RemoveComponent.php b/src/LiveComponent/tests/Fixtures/Component/RemoveComponent.php new file mode 100644 index 00000000000..6de7371e896 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/Component/RemoveComponent.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component; + +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveAction; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\Attribute\PreReRender; +use Symfony\UX\LiveComponent\ComponentToolsTrait; +use Symfony\UX\LiveComponent\DefaultActionTrait; +use Symfony\UX\LiveComponent\LiveResponse; + +#[AsLiveComponent('remove_component', template: 'components/remove_component.html.twig')] +class RemoveComponent +{ + use ComponentToolsTrait; + use DefaultActionTrait; + + public static int $preReRenderCalls = 0; + + #[LiveProp(writable: true)] + public int $count = 0; + + #[LiveAction] + public function dismiss(): LiveResponse + { + ++$this->count; + + return LiveResponse::remove(); + } + + #[LiveAction] + public function dismissWithEvents(): LiveResponse + { + $this->emit('componentRemoved', ['id' => 42]); + $this->dispatchBrowserEvent('component:removed', ['id' => 42]); + + return LiveResponse::remove(); + } + + #[LiveAction] + public function keep(): void + { + ++$this->count; + } + + #[PreReRender] + public function beforeReRender(): void + { + ++self::$preReRenderCalls; + } +} diff --git a/src/LiveComponent/tests/Fixtures/templates/components/remove_component.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/remove_component.html.twig new file mode 100644 index 00000000000..775f57aa75b --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/templates/components/remove_component.html.twig @@ -0,0 +1 @@ +Count: {{ count }} diff --git a/src/LiveComponent/tests/Functional/LiveResponseRemoveTest.php b/src/LiveComponent/tests/Functional/LiveResponseRemoveTest.php new file mode 100644 index 00000000000..4aa7dae2589 --- /dev/null +++ b/src/LiveComponent/tests/Functional/LiveResponseRemoveTest.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Functional; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\LiveComponent\Tests\Fixtures\Component\RemoveComponent; +use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper; +use Zenstruck\Browser\Test\HasBrowser; + +/** + * @author Simon André + */ +final class LiveResponseRemoveTest extends KernelTestCase +{ + use HasBrowser; + use LiveComponentTestHelper; + + protected function setUp(): void + { + RemoveComponent::$preReRenderCalls = 0; + } + + public function testRemoveAnswersWithRenderedHtmlAndTheRemoveHeader() + { + $response = $this->postAction('dismiss')->client()->getResponse(); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('1', $response->headers->get('X-Live-Remove')); + $this->assertSame('application/vnd.live-component+html', $response->headers->get('Content-Type')); + $this->assertStringContainsString('Count: 1', $response->getContent()); + } + + public function testRemoveRendersAndRunsTheHooks() + { + $this->postAction('dismiss'); + + $this->assertSame(1, RemoveComponent::$preReRenderCalls); + } + + public function testRemoveCarriesLiveAndBrowserEventsInTheRenderedHtml() + { + $crawler = $this->postAction('dismissWithEvents')->crawler(); + $element = $crawler->filter('[data-controller~="live"]'); + + $this->assertSame([ + ['event' => 'componentRemoved', 'data' => ['id' => 42], 'target' => null, 'componentName' => null], + ], json_decode($element->attr('data-live-events-to-emit-value'), true)); + $this->assertSame([ + ['event' => 'component:removed', 'payload' => ['id' => 42]], + ], json_decode($element->attr('data-live-events-to-dispatch-value'), true)); + } + + public function testAnOrdinaryActionStillRendersAndRunsTheHooks() + { + $response = $this->postAction('keep')->client()->getResponse(); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertNull($response->headers->get('X-Live-Remove')); + $this->assertStringContainsString('Count: 1', $response->getContent()); + $this->assertSame(1, RemoveComponent::$preReRenderCalls); + } + + private function postAction(string $action): object + { + $dehydrated = $this->dehydrateComponent($this->mountComponent('remove_component')); + + return $this->browser() + ->throwExceptions() + ->post('/_components/remove_component/'.$action, [ + 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])], + ]) + ; + } +} diff --git a/src/LiveComponent/tests/Unit/LiveResponseTest.php b/src/LiveComponent/tests/Unit/LiveResponseTest.php index 97a2cf2231f..6723c2ec9a8 100644 --- a/src/LiveComponent/tests/Unit/LiveResponseTest.php +++ b/src/LiveComponent/tests/Unit/LiveResponseTest.php @@ -144,4 +144,24 @@ public function testDownloadUrlRejectsAnEmptyUrl() LiveResponse::downloadUrl(' '); } + + public function testRemove() + { + $response = LiveResponse::remove(); + + $this->assertTrue($response->isRemove()); + $this->assertNull($response->content); + $this->assertNull($response->url); + } + + public function testDownloadsAreNotRemovals() + { + $this->assertFalse(LiveResponse::downloadFile('x', 'f.bin')->isRemove()); + $this->assertFalse(LiveResponse::downloadUrl('/f.bin')->isRemove()); + } + + public function testARemovalIsNotADownloadUrl() + { + $this->assertFalse(LiveResponse::remove()->isDownloadUrl()); + } }