diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index b1ab97d927b..ea8b4d4b3b8 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -22,6 +22,8 @@ } ``` +- Add `limit` and `visible` modifiers to `data-poll`, alongside new `stopPoll()` and `stopAllPolls()` methods + ## 2.31 - Add browser events assertions in `InteractsWithLiveComponents`: diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 5db5ebdae92..732e77086e7 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1961,27 +1961,70 @@ var PageUnloadingPlugin_default = class { var PollingDirector_default = class { constructor(component) { this.isPollingActive = true; + this.polls = []; this.pollingIntervals = []; + this.isPageVisible = true; + this.isComponentIntersecting = true; + this.memoryCounts = {}; this.component = component; + this.isPageVisible = !document.hidden; + this.visibilityChangeListener = () => { + this.isPageVisible = !document.hidden; + this.evaluatePollingStates(); + }; + document.addEventListener("visibilitychange", this.visibilityChangeListener); + if (typeof IntersectionObserver !== "undefined") { + this.observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + this.isComponentIntersecting = entry.isIntersecting; + this.evaluatePollingStates(); + }); + }); + this.observer.observe(this.component.element); + } } - addPoll(actionName, duration) { - this.polls.push({ + addPoll(actionName, duration, limit = 0, visibilityMode = false) { + const pollConfig = { actionName, - duration - }); - if (this.isPollingActive) this.initiatePoll(actionName, duration); + duration, + limit, + count: this.memoryCounts[actionName] || 0, + visibilityMode + }; + this.polls.push(pollConfig); + if (this.isPollingActive) this.evaluatePollingStates(); } startAllPolling() { if (this.isPollingActive) return; this.isPollingActive = true; - this.polls.forEach(({ actionName, duration }) => { - this.initiatePoll(actionName, duration); + this.evaluatePollingStates(); + } + evaluatePollingStates() { + if (!this.isPollingActive) return; + this.polls.forEach((poll) => { + if (poll.limit > 0 && poll.count >= poll.limit) return; + let shouldRun = true; + if (poll.visibilityMode === "page") shouldRun = this.isPageVisible; + else if (poll.visibilityMode === "component") shouldRun = this.isPageVisible && this.isComponentIntersecting; + const isRunning = this.pollingIntervals.some((i) => i.actionName === poll.actionName); + if (shouldRun && !isRunning) this.initiatePoll(poll); + else if (!shouldRun && isRunning) this.stopPolling(poll.actionName); }); } stopAllPolling() { this.isPollingActive = false; - this.pollingIntervals.forEach((interval) => { - clearInterval(interval); + this.pollingIntervals.forEach(({ timer }) => { + window.clearInterval(timer); + }); + this.pollingIntervals = []; + } + stopPolling(actionName) { + this.pollingIntervals = this.pollingIntervals.filter(({ actionName: intervalAction, timer }) => { + if (intervalAction === actionName) { + window.clearInterval(timer); + return false; + } + return true; }); } clearPolling() { @@ -1989,18 +2032,25 @@ var PollingDirector_default = class { this.polls = []; this.startAllPolling(); } - initiatePoll(actionName, duration) { - let callback; - if (actionName === "$render") callback = () => { - this.component.render(); - }; - else callback = () => { - this.component.action(actionName, {}, 0); - }; + initiatePoll(poll) { + let callback = poll.actionName === "$render" ? () => this.component.render() : () => this.component.action(poll.actionName, {}, 0); const timer = window.setInterval(() => { callback(); - }, duration); - this.pollingIntervals.push(timer); + if (poll.limit > 0) { + poll.count++; + this.memoryCounts[poll.actionName] = poll.count; + if (poll.count >= poll.limit) this.stopPolling(poll.actionName); + } + }, poll.duration); + this.pollingIntervals.push({ + actionName: poll.actionName, + timer + }); + } + destroy() { + document.removeEventListener("visibilitychange", this.visibilityChangeListener); + if (this.observer) this.observer.disconnect(); + this.stopAllPolling(); } }; var PollingPlugin_default = class { @@ -2012,14 +2062,19 @@ var PollingPlugin_default = class { this.pollingDirector.startAllPolling(); }); component.on("disconnect", () => { - this.pollingDirector.stopAllPolling(); + this.pollingDirector.destroy(); }); component.on("render:finished", () => { this.initializePolling(); }); + this.element.addEventListener("live:stop-poll", ((event) => { + const actionToStop = event.detail?.action; + if (actionToStop) this.pollingDirector.stopPolling(actionToStop); + else this.pollingDirector.stopAllPolling(); + })); } - addPoll(actionName, duration) { - this.pollingDirector.addPoll(actionName, duration); + addPoll(actionName, duration, limit, visibilityMode = false) { + this.pollingDirector.addPoll(actionName, duration, limit, visibilityMode); } clearPolling() { this.pollingDirector.clearPolling(); @@ -2030,15 +2085,27 @@ var PollingPlugin_default = class { const rawPollConfig = this.element.dataset.poll; parseDirectives(rawPollConfig || "$render").forEach((directive) => { let duration = 2e3; + let limit = 0; + let visibilityMode = false; directive.modifiers.forEach((modifier) => { switch (modifier.name) { case "delay": if (modifier.value) duration = Number.parseInt(modifier.value); break; + case "limit": + if (modifier.value) { + const parsed = Number.parseInt(modifier.value, 10); + limit = Number.isNaN(parsed) || parsed <= 0 ? 0 : parsed; + } + break; + case "visible": + if (modifier.value === "page") visibilityMode = "page"; + else visibilityMode = "component"; + break; default: console.warn(`Unknown modifier "${modifier.name}" in data-poll "${rawPollConfig}".`); } }); - this.addPoll(directive.action, duration); + this.addPoll(directive.action, duration, limit, visibilityMode); }); } }; diff --git a/src/LiveComponent/assets/src/Component/plugins/PollingPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/PollingPlugin.ts index 8a22cabefb3..fc7ab2c28b5 100644 --- a/src/LiveComponent/assets/src/Component/plugins/PollingPlugin.ts +++ b/src/LiveComponent/assets/src/Component/plugins/PollingPlugin.ts @@ -16,16 +16,32 @@ export default class implements PluginInterface { this.pollingDirector.startAllPolling(); }); component.on('disconnect', () => { - this.pollingDirector.stopAllPolling(); + // Clean up intervals, observers, and event listeners to prevent memory leaks + this.pollingDirector.destroy(); }); component.on('render:finished', () => { // re-start polling, in case polling changed this.initializePolling(); }); + + // Listen for the stop-poll event dispatched from the server (PHP) + this.element.addEventListener('live:stop-poll', ((event: CustomEvent) => { + const actionToStop = event.detail?.action; + if (actionToStop) { + this.pollingDirector.stopPolling(actionToStop); + } else { + this.pollingDirector.stopAllPolling(); + } + }) as EventListener); } - addPoll(actionName: string, duration: number): void { - this.pollingDirector.addPoll(actionName, duration); + addPoll( + actionName: string, + duration: number, + limit: number, + visibilityMode: 'component' | 'page' | false = false + ): void { + this.pollingDirector.addPoll(actionName, duration, limit, visibilityMode); } clearPolling(): void { @@ -44,6 +60,8 @@ export default class implements PluginInterface { directives.forEach((directive) => { let duration = 2000; + let limit = 0; + let visibilityMode: 'component' | 'page' | false = false; // Default: runs always in the background (BC) directive.modifiers.forEach((modifier) => { switch (modifier.name) { @@ -51,14 +69,28 @@ export default class implements PluginInterface { if (modifier.value) { duration = Number.parseInt(modifier.value); } - + break; + case 'limit': + if (modifier.value) { + const parsed = Number.parseInt(modifier.value, 10); + // Fallback to 0 (infinite) if NaN or negative + limit = Number.isNaN(parsed) || parsed <= 0 ? 0 : parsed; + } + break; + case 'visible': + if (modifier.value === 'page') { + visibilityMode = 'page'; + } else { + // Default to 'component' if only "visible" or "visible(component)" is provided + visibilityMode = 'component'; + } break; default: console.warn(`Unknown modifier "${modifier.name}" in data-poll "${rawPollConfig}".`); } }); - this.addPoll(directive.action, duration); + this.addPoll(directive.action, duration, limit, visibilityMode); }); } } diff --git a/src/LiveComponent/assets/src/PollingDirector.ts b/src/LiveComponent/assets/src/PollingDirector.ts index 5fb384a807c..2551a397965 100644 --- a/src/LiveComponent/assets/src/PollingDirector.ts +++ b/src/LiveComponent/assets/src/PollingDirector.ts @@ -1,63 +1,159 @@ import type Component from './Component'; +export interface PollConfig { + actionName: string; + duration: number; + limit: number; + count: number; + visibilityMode: 'component' | 'page' | false; +} + export default class { component: Component; isPollingActive = true; - polls: Array<{ actionName: string; duration: number }>; - pollingIntervals: number[] = []; + polls: PollConfig[] = []; + pollingIntervals: Array<{ actionName: string; timer: number }> = []; + isPageVisible = true; + isComponentIntersecting = true; + observer?: IntersectionObserver; + + // Memory dictionary to keep track of poll counts across component re-renders + private memoryCounts: Record = {}; + private readonly visibilityChangeListener: () => void; constructor(component: Component) { this.component = component; - } + this.isPageVisible = !document.hidden; - addPoll(actionName: string, duration: number) { - this.polls.push({ actionName, duration }); + // 1. Listen for page visibility changes (tab switching + this.visibilityChangeListener = () => { + this.isPageVisible = !document.hidden; + this.evaluatePollingStates(); + }; - if (this.isPollingActive) { - this.initiatePoll(actionName, duration); + document.addEventListener('visibilitychange', this.visibilityChangeListener); + + // 2. Observe component visibility in the viewport + if (typeof IntersectionObserver !== 'undefined') { + this.observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + this.isComponentIntersecting = entry.isIntersecting; + this.evaluatePollingStates(); + }); + }); + this.observer.observe(this.component.element); } } - startAllPolling(): void { + addPoll( + actionName: string, + duration: number, + limit: number = 0, + visibilityMode: 'component' | 'page' | false = false + ) { + // Retrieve count from memory if it exists, otherwise start at 0 + const count = this.memoryCounts[actionName] || 0; + const pollConfig: PollConfig = { actionName, duration, limit, count, visibilityMode }; + this.polls.push(pollConfig); + if (this.isPollingActive) { - return; // already active! + this.evaluatePollingStates(); } + } + startAllPolling(): void { + if (this.isPollingActive) return; this.isPollingActive = true; - this.polls.forEach(({ actionName, duration }) => { - this.initiatePoll(actionName, duration); + // Note: memoryCounts is NOT reset here to preserve limits across re-renders + this.evaluatePollingStates(); + } + + private evaluatePollingStates(): void { + if (!this.isPollingActive) return; + + this.polls.forEach((poll) => { + // Skip if the limit has already been reached + if (poll.limit > 0 && poll.count >= poll.limit) { + return; + } + + let shouldRun = true; + + if (poll.visibilityMode === 'page') { + shouldRun = this.isPageVisible; + } else if (poll.visibilityMode === 'component') { + shouldRun = this.isPageVisible && this.isComponentIntersecting; + } + + const isRunning = this.pollingIntervals.some((i) => i.actionName === poll.actionName); + + // Start polling if conditions are met and it's not currently running + if (shouldRun && !isRunning) { + this.initiatePoll(poll); + } else if (!shouldRun && isRunning) { + // Stop polling if conditions are no longer met + this.stopPolling(poll.actionName); + } }); } stopAllPolling(): void { this.isPollingActive = false; - this.pollingIntervals.forEach((interval) => { - clearInterval(interval); + this.pollingIntervals.forEach(({ timer }) => { + window.clearInterval(timer); + }); + this.pollingIntervals = []; + } + + stopPolling(actionName: string): void { + this.pollingIntervals = this.pollingIntervals.filter(({ actionName: intervalAction, timer }) => { + if (intervalAction === actionName) { + window.clearInterval(timer); + return false; + } + return true; }); } clearPolling(): void { this.stopAllPolling(); this.polls = []; - // set back to "is polling" status this.startAllPolling(); } - private initiatePoll(actionName: string, duration: number): void { - let callback: () => void; - if (actionName === '$render') { - callback = () => { - this.component.render(); - }; - } else { - callback = () => { - this.component.action(actionName, {}, 0); - }; - } + private initiatePoll(poll: PollConfig): void { + let callback: () => void = + poll.actionName === '$render' + ? () => this.component.render() + : () => this.component.action(poll.actionName, {}, 0); const timer = window.setInterval(() => { callback(); - }, duration); - this.pollingIntervals.push(timer); + + // Check and increment the limit inside the interval + if (poll.limit > 0) { + poll.count++; + this.memoryCounts[poll.actionName] = poll.count; + + // Target limit reached, stop this specific poll + if (poll.count >= poll.limit) { + this.stopPolling(poll.actionName); + } + } + }, poll.duration); + + this.pollingIntervals.push({ actionName: poll.actionName, timer }); + } + + /** + * Cleans up event listeners and observers when the component is disconnected. + * Prevents memory leaks in SPA or Turbo Drive environments. + */ + destroy(): void { + document.removeEventListener('visibilitychange', this.visibilityChangeListener); + if (this.observer) { + this.observer.disconnect(); + } + this.stopAllPolling(); } } diff --git a/src/LiveComponent/assets/test/unit/controller/poll.test.ts b/src/LiveComponent/assets/test/unit/controller/poll.test.ts index 7ecadc5d37a..2aeca872965 100644 --- a/src/LiveComponent/assets/test/unit/controller/poll.test.ts +++ b/src/LiveComponent/assets/test/unit/controller/poll.test.ts @@ -210,4 +210,140 @@ describe('LiveController polling Tests', () => { await waitFor(() => expect(test.element).toHaveTextContent('Render count: 1')); await waitFor(() => expect(test.element).toHaveTextContent('Render count: 2')); }); + + it('stops polling after the specified limit is reached', async () => { + const test = await createTest( + { renderCount: 0 }, + (data: any) => ` +
+ Render count: ${data.renderCount} +
+ ` + ); + + // Poll 1 + test.expectsAjaxCall().serverWillChangeProps((data: any) => { + data.renderCount = 1; + }); + // Poll 2 + test.expectsAjaxCall().serverWillChangeProps((data: any) => { + data.renderCount = 2; + }); + + // Wait for both requests to execute successfully + await waitFor(() => expect(test.element).toHaveTextContent('Render count: 1')); + await waitFor(() => expect(test.element).toHaveTextContent('Render count: 2')); + + // Wait a bit longer to ensure a 3rd request is NOT made + const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(true), 300)); + await waitFor(() => timeoutPromise); + + // Ensure the render count remains strictly at 2 + expect(test.element).toHaveTextContent('Render count: 2'); + }); + + it('stops a specific poll via live:stop-poll event', async () => { + const test = await createTest( + { renderCount: 0 }, + (data: any) => ` +
+ Render count: ${data.renderCount} +
+ ` + ); + + // Let the 1st poll execute + test.expectsAjaxCall() + .expectActionCalled('myAction') + .serverWillChangeProps((data: any) => { + data.renderCount = 1; + }); + + await waitFor(() => expect(test.element).toHaveTextContent('Render count: 1')); + + // Dispatch the stop event as if it came from the server (PHP) + test.element.dispatchEvent( + new CustomEvent('live:stop-poll', { + detail: { action: 'myAction' }, + }) + ); + + // Wait and ensure polling has stopped (no 2nd request is made) + const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(true), 300)); + await waitFor(() => timeoutPromise); + + expect(test.element).toHaveTextContent('Render count: 1'); + }); + + it('stops all polling via live:stop-poll event with isAll detail', async () => { + const test = await createTest( + { renderCount: 0 }, + (data: any) => ` +
+ Render count: ${data.renderCount} +
+ ` + ); + + test.expectsAjaxCall().serverWillChangeProps((data: any) => { + data.renderCount = 1; + }); + + await waitFor(() => expect(test.element).toHaveTextContent('Render count: 1')); + + // Stop completely by sending isAll: true + test.element.dispatchEvent( + new CustomEvent('live:stop-poll', { + detail: { isAll: true }, + }) + ); + + const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(true), 300)); + await waitFor(() => timeoutPromise); + + expect(test.element).toHaveTextContent('Render count: 1'); + }); + + it('pauses polling when document is hidden via visible(page) modifier', async () => { + // Preparation to mock document.hidden in JSDOM + let isHidden = false; + Object.defineProperty(document, 'hidden', { + configurable: true, + get: () => isHidden, + }); + + const test = await createTest( + { renderCount: 0 }, + (data: any) => ` +
+ Render count: ${data.renderCount} +
+ ` + ); + + // 1st Poll (While the tab is visible) + test.expectsAjaxCall().serverWillChangeProps((data: any) => { + data.renderCount = 1; + }); + await waitFor(() => expect(test.element).toHaveTextContent('Render count: 1')); + + // Hide the tab and trigger visibilitychange event + isHidden = true; + document.dispatchEvent(new Event('visibilitychange')); + + // Wait... Ajax request SHOULD NOT be sent while hidden + const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(true), 300)); + await waitFor(() => timeoutPromise); + expect(test.element).toHaveTextContent('Render count: 1'); + + // Show the tab again and trigger event + isHidden = false; + document.dispatchEvent(new Event('visibilitychange')); + + // 2nd Poll (Should resume without resetting limits when the tab is visible again) + test.expectsAjaxCall().serverWillChangeProps((data: any) => { + data.renderCount = 2; + }); + await waitFor(() => expect(test.element).toHaveTextContent('Render count: 2')); + }); }); diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index a81edae20a9..f45408909cd 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -2537,6 +2537,60 @@ You can also trigger a specific "action" instead of a normal re-render: #} > +Advanced Polling Modifiers +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can further control polling behavior using the ``limit`` and ``visible`` modifiers: + +* ``limit(n)``: Stops polling after exactly ``n`` successful requests (default is ``0``, which means infinite). +* ``visible`` (or ``visible(component)``): Pauses polling when the component is not visible on the screen or when the browser tab is hidden. It resumes automatically without resetting the limit counter when visible again. +* ``visible(page)``: Pauses polling only when the browser tab is in the background. + +.. code-block:: html+twig + +
+ +Stopping a Poll from PHP +~~~~~~~~~~~~~~~~~~~~~~~~ + +You can dynamically stop a polling loop from your component's PHP class by using the ``ComponentToolsTrait`` and calling ``$this->stopPoll()``:: + + use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; + use Symfony\UX\LiveComponent\Attribute\LiveAction; + use Symfony\UX\LiveComponent\ComponentToolsTrait; + use Symfony\UX\LiveComponent\DefaultActionTrait; + + #[AsLiveComponent] + class ProgressComponent + { + use DefaultActionTrait; + use ComponentToolsTrait; + + public bool $isFinished = false; + + #[LiveAction] + public function checkProgress(): void + { + // ... do some work + + if ($this->isFinished) { + // Stops the specific 'checkProgress' polling loop + $this->stopPoll('checkProgress'); + + // Alternatively, stop the default render polling: + // $this->stopPoll(); + + // Or stop ALL active polling loops on this component: + // $this->stopAllPolls(); + } + } + } + + Changing the URL when a LiveProp changes ---------------------------------------- diff --git a/src/LiveComponent/src/ComponentToolsTrait.php b/src/LiveComponent/src/ComponentToolsTrait.php index 80367425cc6..1e737508a24 100644 --- a/src/LiveComponent/src/ComponentToolsTrait.php +++ b/src/LiveComponent/src/ComponentToolsTrait.php @@ -50,4 +50,26 @@ public function dispatchBrowserEvent(string $eventName, array $payload = []): vo { $this->liveResponder->dispatchBrowserEvent($eventName, $payload); } + + /** + * Stops the data-poll loop in the frontend for a specific action. + * + * @param string $actionName The name of the action to stop. Defaults to the main render loop ('$render'). + */ + protected function stopPoll(string $actionName = '$render'): void + { + $this->dispatchBrowserEvent('live:stop-poll', [ + 'action' => $actionName, + ]); + } + + /** + * Stops all active data-poll loops in the frontend for this component. + */ + protected function stopAllPolls(): void + { + $this->dispatchBrowserEvent('live:stop-poll', [ + 'isAll' => true, + ]); + } }