From 5503ac215977fc3dff6a3f556c825537a5384568 Mon Sep 17 00:00:00 2001 From: Harald Ponce de Leon Date: Sun, 26 Apr 2026 09:06:38 +0200 Subject: [PATCH] [LiveComponent] Re-emit render hooks as live:render:* DOM events --- src/LiveComponent/CHANGELOG.md | 3 ++ .../assets/dist/live_controller.js | 10 ++++++ .../assets/src/live_controller.ts | 7 ++++ .../test/unit/controller/render.test.ts | 35 +++++++++++++++++++ src/LiveComponent/doc/index.rst | 26 ++++++++++++++ 5 files changed, 81 insertions(+) diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index f7f48c0ef2c..026ce79fc7d 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -2,6 +2,9 @@ ## 3.1 +- Re-emit the `render:started` and `render:finished` JS hooks as bubbling + `live:render:started` / `live:render:finished` DOM events on the component's + root element, alongside the existing `live:connect` / `live:disconnect`. - Use `aria-busy` attribute during component re-render ## 3.0.0 diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index d12e653e340..80318c0ba7c 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -2218,6 +2218,16 @@ var LiveControllerDefault = class LiveControllerDefault extends Controller { ].forEach((plugin) => { this.component.addPlugin(plugin); }); + this.component.on("render:started", (html, backendResponse, controls) => { + this.dispatchEvent("render:started", { + html, + backendResponse, + controls + }); + }); + this.component.on("render:finished", () => { + this.dispatchEvent("render:finished"); + }); } connectComponent() { this.component.connect(); diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index 2516eb8cc6f..c53f66fc282 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -313,6 +313,13 @@ export default class LiveControllerDefault extends Controller imple plugins.forEach((plugin) => { this.component.addPlugin(plugin); }); + + this.component.on('render:started', (html, backendResponse, controls) => { + this.dispatchEvent('render:started', { html, backendResponse, controls }); + }); + this.component.on('render:finished', () => { + this.dispatchEvent('render:finished'); + }); } private connectComponent() { diff --git a/src/LiveComponent/assets/test/unit/controller/render.test.ts b/src/LiveComponent/assets/test/unit/controller/render.test.ts index 3fba61182b1..6a29969cf00 100644 --- a/src/LiveComponent/assets/test/unit/controller/render.test.ts +++ b/src/LiveComponent/assets/test/unit/controller/render.test.ts @@ -41,6 +41,41 @@ describe('LiveController rendering Tests', () => { expect(test.component.valueStore.getOriginalProps()).toEqual({ firstName: 'Kevin' }); }); + it('dispatches live:render:started and live:render:finished DOM events', async () => { + const test = await createTest( + { firstName: 'Ryan' }, + (data: any) => ` +
+ Name: ${data.firstName} + +
+ ` + ); + + let startedTriggered = false; + let finishedTriggered = false; + test.element.addEventListener('live:render:started', (event: any) => { + startedTriggered = true; + expect(event.bubbles).toStrictEqual(true); + expect(typeof event.detail.html).toEqual('string'); + expect(event.detail.controls).toEqual({ shouldRender: true }); + }); + test.element.addEventListener('live:render:finished', (event: any) => { + finishedTriggered = true; + expect(event.bubbles).toStrictEqual(true); + }); + + test.expectsAjaxCall().serverWillChangeProps((data: any) => { + data.firstName = 'Kevin'; + }); + + getByText(test.element, 'Reload').click(); + + await waitFor(() => expect(test.element).toHaveTextContent('Name: Kevin')); + expect(startedTriggered).toBe(true); + expect(finishedTriggered).toBe(true); + }); + it('conserves the value of model field that was modified after a render request', async () => { const test = await createTest( { title: 'greetings', comment: '' }, diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index 542bb0151dc..d63b99a2944 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -987,6 +987,32 @@ The following hooks are available (along with the arguments that are passed): * ``loading.state:finished`` args ``(element: HTMLElement)`` * ``model:set`` args ``(model: string, value: any, component: Component)`` +DOM Events +~~~~~~~~~~ + +The ``connect``, ``disconnect``, ``render:started`` and ``render:finished`` JavaScript +hooks are also re-emitted as bubbling DOM events on the component's root element, +prefixed with ``live:``. This lets external code subscribe via plain +``addEventListener`` without needing to grab the ``Component`` instance through +``getComponent()``: + +.. code-block:: javascript + + element.addEventListener('live:render:finished', (event) => { + // event.detail.component — the (proxied) Component instance + // event.detail.controller — the Stimulus controller + }); + +The ``live:render:started`` event additionally carries ``html``, ``backendResponse`` +and ``controls`` in its ``detail``, matching the JS hook signature. + +The following DOM events are dispatched: + +* ``live:connect`` +* ``live:disconnect`` +* ``live:render:started`` +* ``live:render:finished`` + Loading States --------------