From ab8e7f06e8626b6c434f00c33a80b88fb63d424b Mon Sep 17 00:00:00 2001 From: Giovanni Francischelli Date: Mon, 9 Dec 2024 11:14:02 +0200 Subject: [PATCH 1/5] support multiple livesockets Add `rootViewSelector` option to liveSocket constructor so different liveSockets can target different liveview HTML nodes in the page. --- assets/js/phoenix_live_view/live_socket.js | 15 +++++++- guides/client/js-interop.md | 45 ++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/assets/js/phoenix_live_view/live_socket.js b/assets/js/phoenix_live_view/live_socket.js index 1052d60b14..07475ec980 100644 --- a/assets/js/phoenix_live_view/live_socket.js +++ b/assets/js/phoenix_live_view/live_socket.js @@ -70,6 +70,8 @@ * @param {Object} [opts.localStorage] - An optional Storage compatible object * Useful for when LiveView won't have access to `localStorage`. * See `opts.sessionStorage` for examples. + * @param {string} [opts.rootViewSelector] - The optional CSS selector to scope which root LiveViews to connect. + * Useful when running multiple liveSockets, each connected to a different application. */ import { @@ -159,6 +161,7 @@ export default class LiveSocket { this.failsafeJitter = opts.failsafeJitter || FAILSAFE_JITTER this.localStorage = opts.localStorage || window.localStorage this.sessionStorage = opts.sessionStorage || window.sessionStorage + this.rootViewSelector = opts.rootViewSelector this.boundTopLevelEvents = false this.boundEventNames = new Set() this.serverCloseRef = null @@ -366,9 +369,13 @@ export default class LiveSocket { } } + viewSelector(){ + return `${PHX_VIEW_SELECTOR}${this.rootViewSelector || ""}` + } + joinRootViews(){ let rootsFound = false - DOM.all(document, `${PHX_VIEW_SELECTOR}:not([${PHX_PARENT_ID}])`, rootEl => { + DOM.all(document, `${this.viewSelector()}:not([${PHX_PARENT_ID}])`, rootEl => { if(!this.getRootById(rootEl.id)){ let view = this.newRootView(rootEl) view.setHref(this.getHref()) @@ -451,7 +458,11 @@ export default class LiveSocket { } owner(childEl, callback){ - let view = maybe(childEl.closest(PHX_VIEW_SELECTOR), el => this.getViewByEl(el)) || this.main + let view = maybe(childEl.closest(this.viewSelector()), el => this.getViewByEl(el)) + // If there's a rootViewSelector, don't default to `this.main` + // since it's not guaranteed to belong to same liveSocket. + // Maybe `this.embbededMode = boolean()` would be a more clear check? + if(!view && !this.rootViewSelector){ view = this.main } return view && callback ? callback(view) : view } diff --git a/guides/client/js-interop.md b/guides/client/js-interop.md index 801dcf8eb9..d701eb8b95 100644 --- a/guides/client/js-interop.md +++ b/guides/client/js-interop.md @@ -24,6 +24,12 @@ except for the following LiveView specific options: * `uploaders` – a reference to a user-defined uploaders namespace, containing client callbacks for client-side direct-to-cloud uploads. See the [External uploads guide](external-uploads.md) for details. + * `rootViewSelector` - the optional CSS selector to scope which root LiveViews to connect. + Useful when running multiple liveSockets, each connected to a different application. + See the [Connecting multiple livesockets](#connecting-multiple-livesockets) + section below for details. + + a CSS selector to scope which ## Debugging client events @@ -313,3 +319,42 @@ Hooks.Chart = { ``` *Note*: In case a LiveView pushes events and renders content, `handleEvent` callbacks are invoked after the page is updated. Therefore, if the LiveView redirects at the same time it pushes events, callbacks won't be invoked on the old page's elements. Callbacks would be invoked on the redirected page's newly mounted hook elements. + + +### Connecting multiple liveSockets + +LiveView allows connecting more than one `liveSocket`, each targeting different HTML nodes. This is useful to +isolate the development cycle of a subset of the user interface. This means a different Phoenix application hosted +in a different domain, can fully support an embedded LiveView. Think of it as Nested LiveViews, but instead of +process-level isolation, it is a service-level isolation. + +Annotate your root views with a unique HTML attribute or class: + +```elixir +# Main application serving a regular LiveView +use GreatProductWeb.LiveView, container: {:div, "data-app": "root"} + +# Cats application, which will serve the cats component +use CatsWeb.LiveView, container: {:div, "data-app": "cats"} +``` + +And initialise the liveSockets: + +```javascript +# Fetch the disconnected render +let disconnectedCatsHTML = await fetch("https://cats.io/live", { credentials: 'include' }) + .then((response) => response.text()) + .catch((error) => console.error(error)); + +# Append it to HTML +document.queryElementById("#cats-slot").innerHTML = disconnectedCatsHTML + + +# Connect main liveSocket +let liveSocket = new LiveSocket("https://root.io/live", Socket, {rootViewSelector: "[data-app='root']"}) +liveSocket.connect() + +# Connect the cats liveSocket +let liveSocketCats = new LiveSocket("https://cats.io/live", Socket, {rootViewSelector: "[data-app='cats']"}) +liveSocketCats.connect() +``` From 616bec4184080dd27fa61f703b594a1b35a3539a Mon Sep 17 00:00:00 2001 From: Giovanni Francischelli Date: Mon, 9 Dec 2024 13:48:04 +0200 Subject: [PATCH 2/5] fix livesocket test that always pass --- assets/test/live_socket_test.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/assets/test/live_socket_test.js b/assets/test/live_socket_test.js index 9bf96177f1..d76772c399 100644 --- a/assets/test/live_socket_test.js +++ b/assets/test/live_socket_test.js @@ -152,10 +152,8 @@ describe("LiveSocket", () => { let _view = liveSocket.getViewByEl(container(1)) let btn = document.querySelector("button") - let _callback = (view) => { - expect(view.id).toBe(view.id) - } - liveSocket.owner(btn, (view) => view.id) + + liveSocket.owner(btn, (view) => expect(view.id).toBe(_view.id)) }) test("getActiveElement default before LiveSocket activeElement is set", async () => { From 04ed4ebe74ad36b0b933842f4d6351ef57a4a863 Mon Sep 17 00:00:00 2001 From: Giovanni Francischelli Date: Mon, 9 Dec 2024 14:41:27 +0200 Subject: [PATCH 3/5] fix main view selection for dead views --- assets/js/phoenix_live_view/live_socket.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/assets/js/phoenix_live_view/live_socket.js b/assets/js/phoenix_live_view/live_socket.js index 07475ec980..cbe6c9953d 100644 --- a/assets/js/phoenix_live_view/live_socket.js +++ b/assets/js/phoenix_live_view/live_socket.js @@ -364,7 +364,9 @@ export default class LiveSocket { let view = this.newRootView(body) view.setHref(this.getHref()) view.joinDead() - if(!this.main){ this.main = view } + // When there's a rootViewSelector it's not appropriate for document.body to be the + // main view since all the connected elements must be scoped under that selector + if(!this.main && !this.rootViewSelector){this.main = view } window.requestAnimationFrame(() => view.execNewMounted()) } } From 9f7150ff8513c1f3d7823dca62a9cc02e0a399df Mon Sep 17 00:00:00 2001 From: Giovanni Francischelli Date: Mon, 9 Dec 2024 14:41:56 +0200 Subject: [PATCH 4/5] test liveSocket.owner with rootViewSelector --- assets/test/live_socket_test.js | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/assets/test/live_socket_test.js b/assets/test/live_socket_test.js index d76772c399..b3fdb8104a 100644 --- a/assets/test/live_socket_test.js +++ b/assets/test/live_socket_test.js @@ -3,16 +3,22 @@ import LiveSocket from "phoenix_live_view/live_socket" let container = (num) => global.document.getElementById(`container${num}`) -let prepareLiveViewDOM = (document) => { +let createRootViewDiv = (containerNum, cssClass) => { const div = document.createElement("div") - div.setAttribute("data-phx-session", "abc123") - div.setAttribute("data-phx-root-id", "container1") - div.setAttribute("id", "container1") + div.setAttribute("data-phx-session", `abc-${containerNum}`) + div.setAttribute("data-phx-root-id", `container${containerNum}`) + div.setAttribute("id", `container${containerNum}`) + if(cssClass) div.classList.add(cssClass) div.innerHTML = ` ` + return div +} + +let prepareLiveViewDOM = (document) => { + const div = createRootViewDiv(1, "main") const button = div.querySelector("button") const input = div.querySelector("input") button.addEventListener("click", () => { @@ -21,6 +27,8 @@ let prepareLiveViewDOM = (document) => { }, 200) }) document.body.appendChild(div) + + document.body.appendChild(createRootViewDiv(2, "extra")) } describe("LiveSocket", () => { @@ -156,6 +164,19 @@ describe("LiveSocket", () => { liveSocket.owner(btn, (view) => expect(view.id).toBe(_view.id)) }) + test("owner with rootViewSelector option", async () => { + let liveSocket = new LiveSocket("/live", Socket, {rootViewSelector: ".main"}) + liveSocket.connect() + + let _view = liveSocket.getViewByEl(container(1)) + + let btn = document.querySelector(".main button") + liveSocket.owner(btn, (view) => expect(view.id).toBe(_view.id)) + + let btnExtra = document.querySelector(".extra button") + liveSocket.owner(btnExtra, (view) => expect(view).toBe(null)) + }) + test("getActiveElement default before LiveSocket activeElement is set", async () => { let liveSocket = new LiveSocket("/live", Socket) From 6302343f3ba7d276a964e506dc357a41d1753c70 Mon Sep 17 00:00:00 2001 From: Giovanni Francischelli Date: Mon, 6 Jan 2025 10:53:46 +0200 Subject: [PATCH 5/5] rename viewSelector option --- assets/js/phoenix_live_view/live_socket.js | 16 ++++++++-------- assets/test/live_socket_test.js | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/assets/js/phoenix_live_view/live_socket.js b/assets/js/phoenix_live_view/live_socket.js index cbe6c9953d..c547cf14e4 100644 --- a/assets/js/phoenix_live_view/live_socket.js +++ b/assets/js/phoenix_live_view/live_socket.js @@ -70,7 +70,7 @@ * @param {Object} [opts.localStorage] - An optional Storage compatible object * Useful for when LiveView won't have access to `localStorage`. * See `opts.sessionStorage` for examples. - * @param {string} [opts.rootViewSelector] - The optional CSS selector to scope which root LiveViews to connect. + * @param {string} [opts.viewSelector] - The optional CSS selector to scope which root LiveViews to connect. * Useful when running multiple liveSockets, each connected to a different application. */ @@ -161,7 +161,7 @@ export default class LiveSocket { this.failsafeJitter = opts.failsafeJitter || FAILSAFE_JITTER this.localStorage = opts.localStorage || window.localStorage this.sessionStorage = opts.sessionStorage || window.sessionStorage - this.rootViewSelector = opts.rootViewSelector + this.viewSelector = opts.viewSelector this.boundTopLevelEvents = false this.boundEventNames = new Set() this.serverCloseRef = null @@ -364,15 +364,15 @@ export default class LiveSocket { let view = this.newRootView(body) view.setHref(this.getHref()) view.joinDead() - // When there's a rootViewSelector it's not appropriate for document.body to be the - // main view since all the connected elements must be scoped under that selector - if(!this.main && !this.rootViewSelector){this.main = view } + // When there's a custom viewSelector it's not appropriate for document.body to be + // the main view since all the connected elements must be scoped under that selector + if(!this.main && !this.viewSelector){this.main = view } window.requestAnimationFrame(() => view.execNewMounted()) } } viewSelector(){ - return `${PHX_VIEW_SELECTOR}${this.rootViewSelector || ""}` + return this.viewSelector || PHX_VIEW_SELECTOR } joinRootViews(){ @@ -461,10 +461,10 @@ export default class LiveSocket { owner(childEl, callback){ let view = maybe(childEl.closest(this.viewSelector()), el => this.getViewByEl(el)) - // If there's a rootViewSelector, don't default to `this.main` + // If there's a viewSelector, don't default to `this.main` // since it's not guaranteed to belong to same liveSocket. // Maybe `this.embbededMode = boolean()` would be a more clear check? - if(!view && !this.rootViewSelector){ view = this.main } + if(!view && !this.viewSelector){ view = this.main } return view && callback ? callback(view) : view } diff --git a/assets/test/live_socket_test.js b/assets/test/live_socket_test.js index b3fdb8104a..18811cba49 100644 --- a/assets/test/live_socket_test.js +++ b/assets/test/live_socket_test.js @@ -164,8 +164,8 @@ describe("LiveSocket", () => { liveSocket.owner(btn, (view) => expect(view.id).toBe(_view.id)) }) - test("owner with rootViewSelector option", async () => { - let liveSocket = new LiveSocket("/live", Socket, {rootViewSelector: ".main"}) + test.only("owner with viewSelector option", async () => { + let liveSocket = new LiveSocket("/live", Socket, {viewSelector: ".main"}) liveSocket.connect() let _view = liveSocket.getViewByEl(container(1))