diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 06863ce27af..62c502343d6 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -1,8 +1,14 @@ # CHANGELOG +## 2.29.0 + +- Added new modifier `limit` and new Hooks for Poll System. + - New Hooks: `poll:started`, `poll:running`, `poll:paused`, `poll:stopped`, `poll:error` + - Handling Count loop and pause, start, stop, resume, error events + ## 2.28.0 -- Add new modifiers for input validations, useful to prevent unnecessary HTTP requests: +- Add new modifiers for input validations, useful to prevent uneccessary HTTP requests: - `min_length` and `max_length`: validate length from textual input elements - `min_value` and `max_value`: validate value from numeral input elements @@ -364,7 +370,7 @@ live_component: - Unexpected Ajax errors are now displayed in a modal to ease debugging! #467. - Fixed bug where sometimes a live component was broken after hitting "Back: - in your browser - #436. + in your browser" - #436. ## 2.4.0 diff --git a/src/LiveComponent/assets/dist/live_controller.d.ts b/src/LiveComponent/assets/dist/live_controller.d.ts index c032c778f57..9e1b0036c8e 100644 --- a/src/LiveComponent/assets/dist/live_controller.d.ts +++ b/src/LiveComponent/assets/dist/live_controller.d.ts @@ -88,6 +88,31 @@ type ComponentHooks = { 'loading.state:started': (element: HTMLElement, request: export_default$1) => MaybePromise; 'loading.state:finished': (element: HTMLElement) => MaybePromise; 'model:set': (model: string, value: any, component: Component) => MaybePromise; + 'poll:started': (context: { + actionName: string; + limit: number; + }) => MaybePromise; + 'poll:running': (context: { + actionName: string; + count: number; + limit: number; + }) => MaybePromise; + 'poll:paused': (context: { + actionName: string; + count: number; + limit: number; + }) => MaybePromise; + 'poll:stopped': (context: { + actionName: string; + finalCount: number; + limit: number; + }) => MaybePromise; + 'poll:error': (context: { + actionName: string; + finalCount: number; + limit: number; + errorMessage: string; + }) => MaybePromise; }; type ComponentHookName = keyof ComponentHooks; type ComponentHookCallback = T extends ComponentHookName ? ComponentHooks[T] : (...args: any[]) => MaybePromise; @@ -120,6 +145,7 @@ declare class Component { disconnect(): void; on(hookName: T, callback: ComponentHookCallback): void; off(hookName: T, callback: ComponentHookCallback): void; + triggerPollHook(event: 'poll:started' | 'poll:running' | 'poll:paused' | 'poll:stopped' | 'poll:error', context: any): void; set(model: string, value: any, reRender?: boolean, debounce?: number | boolean): Promise; getData(model: string): any; action(name: string, args?: any, debounce?: number | boolean): Promise; diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index b1f8049a6f4..fe263b43116 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1893,6 +1893,14 @@ var Component = class { off(hookName, callback) { this.hooks.unregister(hookName, callback); } + /** + * Trigger Polling Hook Events In PollingDirector + * @param event + * @param context + */ + triggerPollHook(event, context) { + this.hooks.triggerHook(event, context); + } set(model, value, reRender = false, debounce = false) { const promise = this.nextRequestPromise; const modelName = normalizeModelName(model); @@ -2584,52 +2592,195 @@ var PageUnloadingPlugin_default = class { // src/PollingDirector.ts var PollingDirector_default = class { + // Lock system for active polling constructor(component) { - this.isPollingActive = true; - this.pollingIntervals = []; + this.pollingIntervals = /* @__PURE__ */ new Map(); + // actionName → intervalId + this.pollingCounts = /* @__PURE__ */ new Map(); + // actionName → current count + this.pollingConfigs = /* @__PURE__ */ new Map(); + // All Polling's Map + this.pollingStates = /* @__PURE__ */ new Map(); + // All Polling's States Map + this.isPollingRunnig = /* @__PURE__ */ new Map(); this.component = component; } - addPoll(actionName, duration) { - this.polls.push({ actionName, duration }); - if (this.isPollingActive) { - this.initiatePoll(actionName, duration); + addPoll(actionName, duration, limit = 0) { + this.pollingConfigs.set(actionName, { duration, limit }); + if (!this.pollingStates.has(actionName)) { + this.pollingStates.set(actionName, "active"); + } + if (!this.pollingCounts.has(actionName)) { + this.pollingCounts.set(actionName, 0); } + this.initiatePoll(actionName, duration); } + /** + * Start All Pollings In PollingStates Map Entires + */ startAllPolling() { - if (this.isPollingActive) { - return; + for (const [actionName] of this.pollingConfigs.entries()) { + this.start(actionName); } - this.isPollingActive = true; - this.polls.forEach(({ actionName, duration }) => { - this.initiatePoll(actionName, duration); - }); } + /** + * Stop All Pollings In PollingStates Map Entires + */ stopAllPolling() { - this.isPollingActive = false; - this.pollingIntervals.forEach((interval) => { - clearInterval(interval); - }); + for (const [actionName] of this.pollingConfigs.entries()) { + this.stop(actionName); + } } - clearPolling() { - this.stopAllPolling(); - this.polls = []; - this.startAllPolling(); + /** + * Stop All Pollings and Clear All Pollings Data + */ + clearAllPolling(soft = false) { + for (const intervalId of this.pollingIntervals.values()) { + clearTimeout(intervalId); + } + this.pollingIntervals.clear(); + this.pollingConfigs.clear(); + if (!soft) { + this.pollingStates.clear(); + this.pollingCounts.clear(); + } } initiatePoll(actionName, duration) { - let callback; - if (actionName === "$render") { - callback = () => { - this.component.render(); - }; - } else { - callback = () => { - this.component.action(actionName, {}, 0); - }; - } - const timer = window.setInterval(() => { - callback(); + this.isPollingRunnig.set(actionName, false); + const callback = async () => { + if (this.isPollingRunnig.get(actionName)) return; + this.isPollingRunnig.set(actionName, true); + const limit = this.pollingConfigs.get(actionName)?.limit ?? 0; + const currentCount = this.pollingCounts.get(actionName) ?? 0; + if (currentCount === 0) { + this.component.triggerPollHook("poll:started", { actionName, limit }); + } + this.pollingCounts.set(actionName, currentCount + 1); + if (limit > 0 && currentCount >= limit) { + this.stop(actionName); + return; + } + try { + if (actionName === "$render") { + await this.component.render(); + } else { + const response = await this.component.action(actionName, {}, 0); + if (response?.response?.status === 500) { + this.stop(actionName); + throw new Error(this.decodeErrorMessage(await response.getBody())); + } + } + this.component.triggerPollHook("poll:running", { + actionName, + count: currentCount + 1, + limit + }); + } catch (error) { + this.component.triggerPollHook("poll:error", { + actionName, + finalCount: currentCount + 1, + limit, + errorMessage: error instanceof Error ? error.message : String(error) + }); + } finally { + this.isPollingRunnig.set(actionName, false); + } + }; + const intervalId = window.setInterval(() => { + if (this.pollingStates.get(actionName) !== "active") { + clearInterval(intervalId); + this.pollingIntervals.delete(actionName); + return; + } + callback().catch((e) => console.error(e)); }, duration); - this.pollingIntervals.push(timer); + this.pollingIntervals.set(actionName, intervalId); + } + /** + * Pause Polling by action Name + * Pause if polling's status is active only + */ + pause(actionName = "$render") { + if (this.pollingStates.get(actionName) !== "active") return; + const intervalId = this.pollingIntervals.get(actionName); + if (intervalId !== void 0) { + clearInterval(intervalId); + this.pollingIntervals.delete(actionName); + } + this.pollingStates.set(actionName, "paused"); + const count = this.pollingCounts.get(actionName) ?? 0; + const limit = this.pollingConfigs.get(actionName)?.limit ?? 0; + this.component.triggerPollHook("poll:paused", { actionName, count, limit }); + } + /** + * Resume Polling by action Name + * Resume if polling's status is paused only + */ + resume(actionName = "$render") { + const config = this.pollingConfigs.get(actionName); + if (this.pollingStates.get(actionName) !== "paused" || !config) { + return; + } + this.pollingStates.set(actionName, "active"); + this.initiatePoll(actionName, config.duration); + } + /** + * Stop Polling by action Name + * Stop if polling's status is active or paused + */ + stop(actionName = "$render") { + const state = this.pollingStates.get(actionName); + if (state !== "active" && state !== "paused") { + return; + } + const intervalId = this.pollingIntervals.get(actionName); + if (intervalId !== void 0) { + clearInterval(intervalId); + this.pollingIntervals.delete(actionName); + } + const currentCount = this.pollingCounts.get(actionName) ?? 1; + const limit = this.pollingConfigs.get(actionName)?.limit ?? 0; + this.component.triggerPollHook("poll:stopped", { + actionName, + finalCount: currentCount, + limit + }); + this.pollingCounts.delete(actionName); + this.pollingStates.set(actionName, "stopped"); + } + /** + * Start Polling by action Name + * Start if polling's status is stopped only + */ + start(actionName = "$render") { + const config = this.pollingConfigs.get(actionName); + if (!config || this.pollingStates.get(actionName) !== "stopped") return; + this.clearForAction(actionName); + this.pollingCounts.set(actionName, 0); + this.pollingStates.set(actionName, "active"); + this.initiatePoll(actionName, config.duration); + } + /** + * Clear Polling Count and Interval data by action Name + */ + clearForAction(actionName = "$render") { + this.pollingCounts.delete(actionName); + const intervalId = this.pollingIntervals.get(actionName); + if (intervalId !== void 0) { + clearInterval(intervalId); + this.pollingIntervals.delete(actionName); + } + } + /** + * Decode Error Message + */ + decodeErrorMessage(errorMessage) { + errorMessage = errorMessage.split("")[0]?.trim(); + if (errorMessage) { + const decoded = errorMessage.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&"); + return `Poll\xB7error:\xB7${decoded}`; + } + return "Poll error: 500 Internal Server Error"; } }; @@ -2639,6 +2790,7 @@ var PollingPlugin_default = class { this.element = component.element; this.pollingDirector = new PollingDirector_default(component); this.initializePolling(); + component.pollingDirector = this.pollingDirector; component.on("connect", () => { this.pollingDirector.startAllPolling(); }); @@ -2649,11 +2801,11 @@ var PollingPlugin_default = class { this.initializePolling(); }); } - addPoll(actionName, duration) { - this.pollingDirector.addPoll(actionName, duration); + addPoll(actionName, duration, limit) { + this.pollingDirector.addPoll(actionName, duration, limit); } clearPolling() { - this.pollingDirector.clearPolling(); + this.pollingDirector.clearAllPolling(true); } initializePolling() { this.clearPolling(); @@ -2664,18 +2816,26 @@ var PollingPlugin_default = class { const directives = parseDirectives(rawPollConfig || "$render"); directives.forEach((directive) => { let duration = 2e3; + let limit = 0; directive.modifiers.forEach((modifier) => { switch (modifier.name) { case "delay": if (modifier.value) { - duration = Number.parseInt(modifier.value); + const parsed = Number.parseInt(modifier.value); + duration = Number.isNaN(parsed) || parsed <= 0 ? 2e3 : parsed; + } + break; + case "limit": + if (modifier.value) { + const parsed = Number.parseInt(modifier.value); + limit = Number.isNaN(parsed) || parsed <= 0 ? 1 : parsed; } break; default: console.warn(`Unknown modifier "${modifier.name}" in data-poll "${rawPollConfig}".`); } }); - this.addPoll(directive.action, duration); + this.addPoll(directive.action, duration, limit); }); } }; diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 2a7decb6ae4..b3fd83fcbe7 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -25,6 +25,16 @@ export type ComponentHooks = { 'loading.state:started': (element: HTMLElement, request: BackendRequest) => MaybePromise; 'loading.state:finished': (element: HTMLElement) => MaybePromise; 'model:set': (model: string, value: any, component: Component) => MaybePromise; + 'poll:started': (context: { actionName: string; limit: number }) => MaybePromise; + 'poll:running': (context: { actionName: string; count: number; limit: number }) => MaybePromise; + 'poll:paused': (context: { actionName: string; count: number; limit: number }) => MaybePromise; + 'poll:stopped': (context: { actionName: string; finalCount: number; limit: number }) => MaybePromise; + 'poll:error': (context: { + actionName: string; + finalCount: number; + limit: number; + errorMessage: string; + }) => MaybePromise; }; export type ComponentHookName = keyof ComponentHooks; @@ -149,6 +159,19 @@ export default class Component { this.hooks.unregister(hookName, callback); } + /** + * Trigger Polling Hook Events In PollingDirector + * @param event + * @param context + */ + + public triggerPollHook( + event: 'poll:started' | 'poll:running' | 'poll:paused' | 'poll:stopped' | 'poll:error', + context: any + ) { + this.hooks.triggerHook(event, context); + } + set(model: string, value: any, reRender = false, debounce: number | boolean = false): Promise { const promise = this.nextRequestPromise; const modelName = normalizeModelName(model); diff --git a/src/LiveComponent/assets/src/Component/plugins/PollingPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/PollingPlugin.ts index 8a22cabefb3..cb900aa3c17 100644 --- a/src/LiveComponent/assets/src/Component/plugins/PollingPlugin.ts +++ b/src/LiveComponent/assets/src/Component/plugins/PollingPlugin.ts @@ -7,11 +7,14 @@ export default class implements PluginInterface { private element: Element; private pollingDirector: PollingDirector; - attachToComponent(component: Component): void { + attachToComponent(component: Component & { pollingDirector?: PollingDirector }): void { this.element = component.element; this.pollingDirector = new PollingDirector(component); this.initializePolling(); + // access from stimulus_controller + component.pollingDirector = this.pollingDirector; + component.on('connect', () => { this.pollingDirector.startAllPolling(); }); @@ -24,12 +27,12 @@ export default class implements PluginInterface { }); } - addPoll(actionName: string, duration: number): void { - this.pollingDirector.addPoll(actionName, duration); + addPoll(actionName: string, duration: number, limit: number): void { + this.pollingDirector.addPoll(actionName, duration, limit); } clearPolling(): void { - this.pollingDirector.clearPolling(); + this.pollingDirector.clearAllPolling(true); } private initializePolling(): void { @@ -44,21 +47,28 @@ export default class implements PluginInterface { directives.forEach((directive) => { let duration = 2000; + let limit = 0; directive.modifiers.forEach((modifier) => { switch (modifier.name) { case 'delay': if (modifier.value) { - duration = Number.parseInt(modifier.value); + const parsed = Number.parseInt(modifier.value); + duration = Number.isNaN(parsed) || parsed <= 0 ? 2000 : parsed; + } + break; + case 'limit': + if (modifier.value) { + const parsed = Number.parseInt(modifier.value); + limit = Number.isNaN(parsed) || parsed <= 0 ? 1 : parsed; } - break; default: console.warn(`Unknown modifier "${modifier.name}" in data-poll "${rawPollConfig}".`); } }); - this.addPoll(directive.action, duration); + this.addPoll(directive.action, duration, limit); }); } } diff --git a/src/LiveComponent/assets/src/PollingDirector.ts b/src/LiveComponent/assets/src/PollingDirector.ts index 5fb384a807c..78c8a1dc12a 100644 --- a/src/LiveComponent/assets/src/PollingDirector.ts +++ b/src/LiveComponent/assets/src/PollingDirector.ts @@ -2,62 +2,222 @@ import type Component from './Component'; export default class { component: Component; - isPollingActive = true; - polls: Array<{ actionName: string; duration: number }>; - pollingIntervals: number[] = []; + pollingIntervals: Map = new Map(); // actionName → intervalId + pollingCounts: Map = new Map(); // actionName → current count + pollingConfigs: Map = new Map(); // All Polling's Map + pollingStates: Map = new Map(); // All Polling's States Map + isPollingRunnig: Map = new Map(); // Lock system for active polling constructor(component: Component) { this.component = component; } - addPoll(actionName: string, duration: number) { - this.polls.push({ actionName, duration }); + addPoll(actionName: string, duration: number, limit = 0) { + this.pollingConfigs.set(actionName, { duration, limit }); - if (this.isPollingActive) { - this.initiatePoll(actionName, duration); + if (!this.pollingStates.has(actionName)) { + this.pollingStates.set(actionName, 'active'); } + if (!this.pollingCounts.has(actionName)) { + this.pollingCounts.set(actionName, 0); + } + this.initiatePoll(actionName, duration); } + /** + * Start All Pollings In PollingStates Map Entires + */ startAllPolling(): void { - if (this.isPollingActive) { - return; // already active! + // this.clearAllPolling(true); + for (const [actionName] of this.pollingConfigs.entries()) { + this.start(actionName); } - - this.isPollingActive = true; - this.polls.forEach(({ actionName, duration }) => { - this.initiatePoll(actionName, duration); - }); } + /** + * Stop All Pollings In PollingStates Map Entires + */ stopAllPolling(): void { - this.isPollingActive = false; - this.pollingIntervals.forEach((interval) => { - clearInterval(interval); - }); + for (const [actionName] of this.pollingConfigs.entries()) { + this.stop(actionName); + } } - clearPolling(): void { - this.stopAllPolling(); - this.polls = []; - // set back to "is polling" status - this.startAllPolling(); + /** + * Stop All Pollings and Clear All Pollings Data + */ + clearAllPolling(soft = false): void { + for (const intervalId of this.pollingIntervals.values()) { + clearTimeout(intervalId); + } + + this.pollingIntervals.clear(); + this.pollingConfigs.clear(); + if (!soft) { + this.pollingStates.clear(); + this.pollingCounts.clear(); + } } private initiatePoll(actionName: string, duration: number): void { - let callback: () => void; - if (actionName === '$render') { - callback = () => { - this.component.render(); - }; - } else { - callback = () => { - this.component.action(actionName, {}, 0); - }; - } - - const timer = window.setInterval(() => { - callback(); + this.isPollingRunnig.set(actionName, false); + const callback = async () => { + if (this.isPollingRunnig.get(actionName)) return; + this.isPollingRunnig.set(actionName, true); + + const limit = this.pollingConfigs.get(actionName)?.limit ?? 0; + const currentCount = this.pollingCounts.get(actionName) ?? 0; + + if (currentCount === 0) { + this.component.triggerPollHook('poll:started', { actionName, limit }); + } + this.pollingCounts.set(actionName, currentCount + 1); + + if (limit > 0 && currentCount >= limit) { + this.stop(actionName); + return; + } + + try { + if (actionName === '$render') { + await this.component.render(); + } else { + const response = await this.component.action(actionName, {}, 0); + + if (response?.response?.status === 500) { + this.stop(actionName); + throw new Error(this.decodeErrorMessage(await response.getBody())); + } + } + this.component.triggerPollHook('poll:running', { + actionName: actionName, + count: currentCount + 1, + limit: limit, + }); + } catch (error) { + this.component.triggerPollHook('poll:error', { + actionName: actionName, + finalCount: currentCount + 1, + limit: limit, + errorMessage: error instanceof Error ? error.message : String(error), + }); + } finally { + this.isPollingRunnig.set(actionName, false); + } + }; + + const intervalId = window.setInterval(() => { + if (this.pollingStates.get(actionName) !== 'active') { + clearInterval(intervalId); + this.pollingIntervals.delete(actionName); + return; + } + + callback().catch((e) => console.error(e)); }, duration); - this.pollingIntervals.push(timer); + + this.pollingIntervals.set(actionName, intervalId); + } + + /** + * Pause Polling by action Name + * Pause if polling's status is active only + */ + pause(actionName = '$render'): void { + if (this.pollingStates.get(actionName) !== 'active') return; + + const intervalId = this.pollingIntervals.get(actionName); + if (intervalId !== undefined) { + clearInterval(intervalId); + this.pollingIntervals.delete(actionName); + } + this.pollingStates.set(actionName, 'paused'); + const count = this.pollingCounts.get(actionName) ?? 0; + const limit = this.pollingConfigs.get(actionName)?.limit ?? 0; + this.component.triggerPollHook('poll:paused', { actionName, count, limit }); + } + + /** + * Resume Polling by action Name + * Resume if polling's status is paused only + */ + resume(actionName = '$render'): void { + const config = this.pollingConfigs.get(actionName); + if (this.pollingStates.get(actionName) !== 'paused' || !config) { + return; + } + this.pollingStates.set(actionName, 'active'); + this.initiatePoll(actionName, config.duration); + } + + /** + * Stop Polling by action Name + * Stop if polling's status is active or paused + */ + stop(actionName = '$render'): void { + const state = this.pollingStates.get(actionName); + if (state !== 'active' && state !== 'paused') { + return; + } + const intervalId = this.pollingIntervals.get(actionName); + if (intervalId !== undefined) { + clearInterval(intervalId); + this.pollingIntervals.delete(actionName); + } + + const currentCount = this.pollingCounts.get(actionName) ?? 1; + const limit = this.pollingConfigs.get(actionName)?.limit ?? 0; + this.component.triggerPollHook('poll:stopped', { + actionName: actionName, + finalCount: currentCount, + limit: limit, + }); + + this.pollingCounts.delete(actionName); + this.pollingStates.set(actionName, 'stopped'); + } + + /** + * Start Polling by action Name + * Start if polling's status is stopped only + */ + start(actionName = '$render'): void { + const config = this.pollingConfigs.get(actionName); + if (!config || this.pollingStates.get(actionName) !== 'stopped') return; + + this.clearForAction(actionName); + this.pollingCounts.set(actionName, 0); + this.pollingStates.set(actionName, 'active'); + + this.initiatePoll(actionName, config.duration); + } + + /** + * Clear Polling Count and Interval data by action Name + */ + clearForAction(actionName = '$render') { + this.pollingCounts.delete(actionName); + const intervalId = this.pollingIntervals.get(actionName); + if (intervalId !== undefined) { + clearInterval(intervalId); + this.pollingIntervals.delete(actionName); + } + } + + /** + * Decode Error Message + */ + decodeErrorMessage(errorMessage: string): string { + errorMessage = errorMessage.split('')[0]?.trim(); + if (errorMessage) { + const decoded = errorMessage + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); + return `Poll·error:·${decoded}`; + } + return 'Poll error: 500 Internal Server Error'; } } diff --git a/src/LiveComponent/assets/test/controller/poll.test.ts b/src/LiveComponent/assets/test/controller/poll.test.ts index d45a5b4ca19..680743a251a 100644 --- a/src/LiveComponent/assets/test/controller/poll.test.ts +++ b/src/LiveComponent/assets/test/controller/poll.test.ts @@ -114,32 +114,32 @@ describe('LiveController polling Tests', () => { ` ); - // poll 1 - test.expectsAjaxCall().serverWillChangeProps((data: any) => { + test.expectsAjaxCall('$render').serverWillChangeProps((data: any) => { data.renderCount = 1; }); - // poll 2 - test.expectsAjaxCall().serverWillChangeProps((data: any) => { + + await waitFor( + () => { + expect(test.element).toHaveTextContent('Render count: 1'); + }, + { timeout: 500 } + ); + + test.expectsAjaxCall('$render').serverWillChangeProps((data: any) => { data.renderCount = 2; data.keepPolling = false; }); - // only wait for about 250ms this time - await waitFor(() => expect(test.element).toHaveTextContent('Render count: 1'), { - timeout: 300, - }); - await waitFor(() => expect(test.element).toHaveTextContent('Render count: 2'), { - timeout: 300, - }); - // wait 500ms more... no more Ajax calls should be made - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => { - resolve(true); - }, 500); - }); - await waitFor(() => timeoutPromise, { - timeout: 750, - }); + await waitFor( + () => { + expect(test.element).toHaveTextContent('Render count: 2'); + }, + { timeout: 500 } + ); + + expect(test.component.pollingDirector.pollingConfigs.get('$render')).toBeUndefined(); + + await new Promise((resolve) => setTimeout(resolve, 500)); }); it('stops polling after it disconnects', async () => { @@ -210,4 +210,259 @@ describe('LiveController polling Tests', () => { await waitFor(() => expect(test.element).toHaveTextContent('Render count: 1')); await waitFor(() => expect(test.element).toHaveTextContent('Render count: 2')); }); + + it('polls stop after limit reached', async () => { + const test = await createTest( + { renderCount: 0 }, + (data: any) => ` +
+ Render count: ${data.renderCount} +
+ ` + ); + + test.expectsAjaxCall().serverWillChangeProps((data: any) => { + data.renderCount = 1; + }); + test.expectsAjaxCall().serverWillChangeProps((data: any) => { + data.renderCount = 2; + }); + test.expectsAjaxCall().serverWillChangeProps((data: any) => { + data.renderCount = 3; + }); + + await waitFor(() => expect(test.element).toHaveTextContent('Render count: 1'), { timeout: 500 }); + await waitFor(() => expect(test.element).toHaveTextContent('Render count: 2'), { timeout: 500 }); + await waitFor(() => expect(test.element).toHaveTextContent('Render count: 3'), { timeout: 500 }); + + // Add a small delay to ensure no more renders happen + await new Promise((r) => setTimeout(r, 200)); + expect(test.element).toHaveTextContent('Render count: 3'); + }); + + it('respects polling limit correctly and stops polling after limit is hit', async () => { + const test = await createTest( + { count: 0 }, + (data) => ` +
+ Count: ${data.count} +
+ ` + ); + + test.expectsAjaxCall().serverWillChangeProps((data) => { + data.count = 1; + }); + test.expectsAjaxCall().serverWillChangeProps((data) => { + data.count = 2; + }); + + await waitFor(() => expect(test.element).toHaveTextContent('Count: 2')); + + await new Promise((r) => setTimeout(r, 200)); + expect(test.element).toHaveTextContent('Count: 2'); // still 2 after limit + }); + + it('can pause and resume polling manually', async () => { + const test = await createTest( + { count: 0 }, + (data) => ` +
+ Count: ${data.count} +
+ ` + ); + + test.expectsAjaxCall().serverWillChangeProps((data) => { + data.count = 1; + }); + + await waitFor(() => expect(test.element).toHaveTextContent('Count: 1')); + + test.component.pollingDirector.pause('$render'); + + await new Promise((r) => setTimeout(r, 300)); + expect(test.element).toHaveTextContent('Count: 1'); + + test.component.pollingDirector.resume('$render'); + + test.expectsAjaxCall().serverWillChangeProps((data) => { + data.count = 2; + }); + + await waitFor(() => expect(test.element).toHaveTextContent('Count: 2')); + }); + + it('can restart polling after stopping', async () => { + const test = await createTest( + { a: 0 }, + (data) => ` +
+ A: ${data.a} +
+ ` + ); + + test.expectsAjaxCall('$render').serverWillChangeProps((data) => { + data.a = 1; + return data; + }); + await waitFor(() => { + expect(test.element).toHaveTextContent('A: 1'); + }); + + test.component.pollingDirector.stop('$render'); + const countStopped = test.component.pollingDirector.pollingCounts.get('$render') ?? 0; + + test.component.pollingDirector.start('$render'); + test.expectsAjaxCall('$render').serverWillChangeProps((data) => { + data.a = 2; + return data; + }); + + await waitFor( + () => { + expect(test.element).toHaveTextContent('A: 2'); + expect(test.component.pollingDirector.pollingCounts.get('$render')).toBeGreaterThan(countStopped); + }, + { timeout: 2000 } + ); + }); + + it('should respect polling limits', async () => { + const test = await createTest( + { count: 0 }, + (data) => ` +
+ Count: ${data.count} +
+ ` + ); + + test.expectsAjaxCall().serverWillChangeProps((data) => { + data.count = 1; + }); + test.expectsAjaxCall().serverWillChangeProps((data) => { + data.count = 2; + }); + + await waitFor(() => expect(test.element).toHaveTextContent('Count: 2')); + + const count = test.component.pollingDirector.pollingCounts.get('$render'); + expect(count).toBe(2); + }); + + it('does not crash if poll limit is non-numeric or malformed', async () => { + const test = await createTest( + { count: 0 }, + (data) => ` +
+ Count: ${data.count} +
+ ` + ); + + test.expectsAjaxCall().serverWillChangeProps((data) => { + data.count = 999; + }); + + await waitFor(() => expect(test.element).toHaveTextContent('Count: 999')); + }); + + it('should handle invalid poll configs gracefully', async () => { + const test = await createTest( + { count: 0 }, + (data) => ` +
+ Count: ${data.count} +
+ ` + ); + + test.expectsAjaxCall().serverWillChangeProps((data) => { + data.count = 1; + }); + + await waitFor(() => expect(test.element).toHaveTextContent('Count: 1'), { + timeout: 2500, + }); + }); + + it('polling triggers poll hooks correctly', async () => { + const test = await createTest( + { count: 0 }, + (data) => ` +
+ Count: ${data.count} +
+ ` + ); + + // An array to track all triggered hooks and their arguments + const calledHooks: Array<{ name: string; args: any }> = []; + const originalTriggerHook = test.component.triggerPollHook.bind(test.component); + test.component.triggerPollHook = (hookName, args) => { + calledHooks.push({ name: hookName, args }); + return originalTriggerHook(hookName, args); + }; + + // Expect two Ajax calls triggered by polling + test.expectsAjaxCall('$render').serverWillChangeProps((props) => { + props.count = 1; + }); + test.expectsAjaxCall('$render').serverWillChangeProps((props) => { + props.count = 2; + }); + + // Wait for the polling to finish and assert hook behavior and DOM updates + await waitFor( + () => { + expect(test.element).toHaveTextContent('Count: 2'); + expect(calledHooks.filter((h) => h.name === 'poll:running').length).toBe(2); + expect(calledHooks.map((h) => h.name)).toContain('poll:stopped'); + }, + { + timeout: 5000, + } + ); + }); + + it('triggers poll:error hook when polling action throws', async () => { + const test = await createTest( + { count: 0 }, + (data) => ` +
+ Count: ${data.count} +
+ ` + ); + + const calledHooks: Array<{ name: string; args: any }> = []; + const originalTriggerHook = test.component.triggerPollHook.bind(test.component); + test.component.triggerPollHook = (hookName, args) => { + console.log(`triggerPollHook called: ${hookName}`, args); + calledHooks.push({ name: hookName, args }); + return originalTriggerHook(hookName, args); + }; + + const originalAction = test.component.action.bind(test.component); + test.component.action = async (actionName, args, delay) => { + if (actionName === 'failPoll') { + throw new Error('Forced failure for testing'); + } + return originalAction(actionName, args, delay); + }; + + await waitFor( + () => { + const errorHook = calledHooks.find((h) => h.name === 'poll:error'); + expect(errorHook).toBeDefined(); + expect(errorHook?.args.actionName).toBe('failPoll'); + expect(errorHook?.args.errorMessage).toBe('Forced failure for testing'); + }, + { + timeout: 5000, + } + ); + }); }); diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index 9701f13fc82..ead1b609e64 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -2523,41 +2523,136 @@ To change the initial tag from a ``div`` to something else, use the ``loading-ta Polling ------- -You can also use "polling" to continually refresh a component. On the -**top-level** element for your component, add ``data-poll``: +.. versionadded:: 2.29 -.. code-block:: diff + The ``limit`` modifier and poll lifecycle hooks (``poll:started``, ``poll:running``, ``poll:stopped``, ``poll:paused``, and ``poll:error``) were introduced in UX LiveComponent 2.29. + +You can use the ``data-poll`` attribute to automatically repeat a component action at a specific interval. This is useful for live updates, status checks, or real-time UI interactions. -
+Basic usage +^^^^^^^^^^^ -This will make a request every 2 seconds to re-render the component. You -can change this by adding a ``delay()`` modifier. When you do this, you -need to be specific that you want to call the ``$render`` method. To -delay for 500ms: +By default, polling runs the ``$render`` action every 2000 milliseconds (2 seconds). .. code-block:: html+twig -
+ {# Unlimited polling every 2 seconds (default) using $render #} +
...
+ + {# Poll every 5 seconds, up to 20 times, using $render #} +
...
+ + {# Poll a custom action (savePost) every 3 seconds, up to 10 times #} +
...
+ +Available Modifiers +^^^^^^^^^^^^^^^^^^^ + +- ``delay(ms)`` — The delay between polls in milliseconds (default: ``2000``) +- ``limit(n)`` — Maximum number of times to run the poll (default: unlimited) +- ``actionName`` — The component action to call (default: ``$render``) + +Poll Hooks +^^^^^^^^^^ -You can also trigger a specific "action" instead of a normal re-render: +The component emits lifecycle hooks during polling. You can listen to these using JavaScript, for example: + +.. code-block:: javascript + + // controllers/poll_controller.js + import { Controller } from '@hotwired/stimulus'; + import { getComponent } from '@symfony/ux-live-component'; + + export default class extends Controller { + async connect() { + this.component = await getComponent(this.element); + + // Disable default error window (optional) + this.component.on('response:error', (backendResponse, controls) => { + controls.displayError = false; + }); + + this.component.on('poll:started', ({ actionName, limit }) => { + console.log(`Polling started: ${actionName}, limit: ${limit}`); + }); + + this.component.on('poll:running', ({ actionName, count, limit }) => { + console.log(`Polling running: ${actionName} (${count}/${limit})`); + }); + + this.component.on('poll:paused', ({ actionName, count, limit }) => { + console.log(`Polling paused: ${actionName}`); + }); + + this.component.on('poll:stopped', ({ actionName, finalCount, limit }) => { + console.log(`Polling stopped: ${actionName}, total runs: ${finalCount}`); + }); + + this.component.on('poll:error', ({ actionName, finalCount, limit, errorMessage }) => { + console.error(`Polling error on ${actionName}: ${errorMessage}`); + }); + } + } + +.. note:: + + These events are dispatched on the component and can be handled using Stimulus. You must retrieve the component instance with ``getComponent(this.element)`` before accessing event listeners. + +Handling Poll Actions (Start, Stop, Pause, Resume) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Starting from version 2.29, polling can be programmatically managed using the ``pollingDirector`` API. + +This allows you to start, pause, resume, or stop polling dynamically for a given action. .. code-block:: html+twig -
+ + +
- data-poll="save" - {# - Or add a delay() modifier: - data-poll="delay(2000)|save" - #} - > +.. code-block:: javascript + + // controllers/poll_controller.js + import { Controller } from '@hotwired/stimulus'; + import { getComponent } from '@symfony/ux-live-component'; + + export default class extends Controller { + static values = { + action: String + } + + async start(event) { + const actionName = event.params.action; + (await getComponent(this.element)).pollingDirector.start(actionName); + } + + async stop(event) { + const actionName = event.params.action; + (await getComponent(this.element)).pollingDirector.stop(actionName); + } + + async pause(event) { + const actionName = event.params.action; + (await getComponent(this.element)).pollingDirector.pause(actionName); + } + + async resume(event) { + const actionName = event.params.action; + (await getComponent(this.element)).pollingDirector.resume(actionName); + } + } + +Available Methods +^^^^^^^^^^^^^^^^^ + +The ``pollingDirector`` API exposes the following methods: + +- ``component.pollingDirector.start(actionName)`` — Starts polling for the given action (if previously stopped). +- ``component.pollingDirector.pause(actionName)`` — Temporarily pauses polling. Can be resumed later. +- ``component.pollingDirector.resume(actionName)`` — Resumes a previously paused poll. +- ``component.pollingDirector.stop(actionName)`` — Stops polling entirely. Use ``start()`` to restart. Changing the URL when a LiveProp changes ----------------------------------------