Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

support multiple livesockets #3564

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions assets/js/phoenix_live_view/live_socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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
}

Expand Down
35 changes: 27 additions & 8 deletions assets/test/live_socket_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<label for="plus">Plus</label>
<input id="plus" value="1" />
<button phx-click="inc_temperature">Inc Temperature</button>
`
return div
}

let prepareLiveViewDOM = (document) => {
const div = createRootViewDiv(1, "main")
const button = div.querySelector("button")
const input = div.querySelector("input")
button.addEventListener("click", () => {
Expand All @@ -21,6 +27,8 @@ let prepareLiveViewDOM = (document) => {
}, 200)
})
document.body.appendChild(div)

document.body.appendChild(createRootViewDiv(2, "extra"))
}

describe("LiveSocket", () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
45 changes: 45 additions & 0 deletions guides/client/js-interop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't document this as prominently. I think the documentation for the liveSocket option is enough.

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' })
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would that be a controller doing a live_render, or do you actually have a live "/live" route in your router?

.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()
```