diff --git a/assets/js/phoenix_live_view/live_socket.js b/assets/js/phoenix_live_view/live_socket.js
index 1052d60b14..c547cf14e4 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.viewSelector] - 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.viewSelector = opts.viewSelector
this.boundTopLevelEvents = false
this.boundEventNames = new Set()
this.serverCloseRef = null
@@ -361,14 +364,20 @@ 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 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 this.viewSelector || PHX_VIEW_SELECTOR
+ }
+
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 +460,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 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.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 9bf96177f1..18811cba49 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", () => {
@@ -152,10 +160,21 @@ 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.only("owner with viewSelector option", async () => {
+ let liveSocket = new LiveSocket("/live", Socket, {viewSelector: ".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 () => {
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()
+```