Skip to content

Commit

Permalink
New: renderDetached method for third party integrations
Browse files Browse the repository at this point in the history
  • Loading branch information
raquo committed Jul 8, 2023
1 parent 6ef4491 commit 6f83e77
Show file tree
Hide file tree
Showing 5 changed files with 203 additions and 2 deletions.
2 changes: 1 addition & 1 deletion src/main/scala/com/raquo/laminar/DomApi.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import com.raquo.ew._
import com.raquo.laminar.api.L.svg
import com.raquo.laminar.keys.{AriaAttr, EventProcessor, HtmlAttr, HtmlProp, StyleProp, SvgAttr}
import com.raquo.laminar.modifiers.EventListener
import com.raquo.laminar.nodes.{ChildNode, CommentNode, ParentNode, ReactiveElement, ReactiveHtmlElement, ReactiveSvgElement, TextNode}
import com.raquo.laminar.nodes.{CommentNode, ReactiveElement, ReactiveHtmlElement, ReactiveSvgElement, TextNode}
import com.raquo.laminar.tags.{HtmlTag, SvgTag, Tag}
import org.scalajs.dom

Expand Down
29 changes: 28 additions & 1 deletion src/main/scala/com/raquo/laminar/api/Laminar.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import com.raquo.laminar.defs.tags.{HtmlTags, SvgTags}
import com.raquo.laminar.keys._
import com.raquo.laminar.lifecycle.InsertContext
import com.raquo.laminar.modifiers.{EventListener, KeyUpdater}
import com.raquo.laminar.nodes.{ReactiveElement, ReactiveHtmlElement, ReactiveSvgElement}
import com.raquo.laminar.nodes.{DetachedRoot, ReactiveElement, ReactiveHtmlElement, ReactiveSvgElement}
import com.raquo.laminar.receivers._
import com.raquo.laminar.tags.{HtmlTag, SvgTag}
import com.raquo.laminar.{DomApi, Implicits, keys, lifecycle, modifiers, nodes}
Expand Down Expand Up @@ -237,13 +237,23 @@ trait Laminar
type TextArea = nodes.ReactiveHtmlElement[dom.html.TextArea]


/** Render a Laminar element into a container DOM node, right now.
* You must make sure that the container node already exists
* in the DOM, otherwise this method will throw.
*/
@inline def render(
container: dom.Element,
rootNode: nodes.ReactiveElement.Base
): RootNode = {
new RootNode(container, rootNode)
}

/** Wait for `DOMContentLoaded` event to fire, then render a Laminar
* element into a container DOM node. This is probably what you want
* to initialize your Laminar application on page load.
*
* See https://developer.mozilla.org/en-US/docs/Web/API/Window/DOMContentLoaded_event
*/
@inline def renderOnDomContentLoaded(
container: => dom.Element,
rootNode: => nodes.ReactiveElement.Base
Expand All @@ -253,6 +263,23 @@ trait Laminar
}(unsafeWindowOwner)
}

/** Wrap a Laminar element in [[DetachedRoot]], which allows you to
* manually activate and deactivate Laminar subscriptions on this
* element. Pass `activateNow = true` to activate the subscriptions
* upon calling this method, or call `.activate()` manually.
*
* Unlike other `render*` methods, this one does NOT attach the element
* to any container / parent DOM node. Instead, you can obtain the JS DOM
* node as `.ref`, and pass it to a third party JS library that requires
* you to provide a DOM node (which it will attach to the DOM on its own).
*/
def renderDetached[El <: ReactiveElement.Base](
rootNode: => El,
activateNow: Boolean
): DetachedRoot[El] = {
new DetachedRoot(rootNode, activateNow)
}

/** A universal Modifier that does nothing */
val emptyMod: Modifier[ReactiveElement.Base] = Modifier.empty

Expand Down
33 changes: 33 additions & 0 deletions src/main/scala/com/raquo/laminar/nodes/DetachedRoot.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.raquo.laminar.nodes

/** This class lets you manually manage the lifecycle of a single
* Laminar element. This is useful when you need to pass a DOM
* element to a third party JS library, and you want that element
* to be managed by Laminar.
*/
class DetachedRoot[+El <: ReactiveElement.Base](
val node: El,
activateNow: Boolean
) {

if (activateNow) {
activate()
}

@inline def ref: node.ref.type = node.ref

/** Start the element's subscriptions, as if it was mounted. */
def activate(): Unit = {
node.dynamicOwner.activate()
}

/** Stop the element's subscriptions, as if it was unmounted. */
def deactivate(): Unit = {
node.dynamicOwner.deactivate()
}

/** Returns true if the element's subscriptions are currently active. */
def isActive: Boolean = {
node.dynamicOwner.isActive
}
}
85 changes: 85 additions & 0 deletions src/test/scala/com/raquo/laminar/DetachedRootSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.raquo.laminar

import com.raquo.laminar.api.L._
import com.raquo.laminar.utils.UnitSpec

class DetachedRootSpec extends UnitSpec {

it("node lifecycle - activateNow = false") {

val nameVar = Var("world")

val root = renderDetached(
div("hello", child.text <-- nameVar),
activateNow = false
)

expectNode(
root.node.ref,
div of ("hello", sentinel)
)

// --

assertEquals(root.isActive, false)

root.activate()

expectNode(
root.node.ref,
div of ("hello", "world")
)

assertEquals(root.isActive, true)

// --

root.deactivate()

nameVar.set("ignored-name")

expectNode(
root.node.ref,
div of("hello", "world")
)

assertEquals(root.isActive, false)
}

it("node lifecycle - activateNow = true") {

val nameVar = Var("world")

val root = renderDetached(
div("hello", child.text <-- nameVar),
activateNow = true
)

expectNode(
root.node.ref,
div of ("hello", "world")
)

assertEquals(root.isActive, true)

// --

nameVar.set("you")

expectNode(
root.node.ref,
div of("hello", "you")
)

// --

root.deactivate()

nameVar.set("ignored-name")

expectNode(
root.node.ref,
div of("hello", "you")
)
}
}
56 changes: 56 additions & 0 deletions website/docs/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -2226,6 +2226,62 @@ You can see some examples of such interfaces and their usage in [live examples](
If you review the source code of all these bindings, you'll see that there's no special API for Web Components in Laminar, all we do is define custom attributes / properties / slots / methods that the given Web Component exposes, and use one simple pattern for accessing them in a scoped manner.


### Passing Laminar Elements to Third Party Libraries

Some libraries expect you to pass them a DOM element, that they will "render" (attach to their DOM) themselves. For example, a Javascript modal library might expect you to specify the content to render in this way.

```scala
object JsModalLibrary {
def renderModal(element: dom.Element) = ???
}
```

All Laminar elements have a `.ref` property that returns the JS DOM node, so it _seems_ as if you can just pass that node to the JS library:

```scala
val content = div(b("Important"), " message")
JsModalLibrary.renderModal(content.ref)
```

However, this only works so long as your `content` Laminar element is static, that is, does not contain any bindings / subscriptions like `<--` and `-->`. A Laminar element's subscriptions only get activated when it is mounted into the DOM (by Laminar), but here we're delegating this mounting responsibility to `JsModalLibrary`, who on its own knows nothing about Laminar's needs.

For such cases, Laminar offers a special `renderDetached` method that lets you manually control the activation and deactivation of subscriptions on a Laminar element:

```scala
val content = div(
b("Important"), " message",
button(onClick --> { _ => dom.console.log("click") }, "Log")
)

val contentRoot: DetachedRoot[Div] = renderDetached(
content,
activateNow = true
)

JsModalLibrary.renderModal(contentRoot.ref)
```

Now, this `contentRoot` detached root is managing the lifecycle of `content` element's subscriptions. We said `activateNow = true`, so the `onClick` subscription will be activated immediately upon creation of the `contentRoot`, and so it will log "click" when the user clicks the button.

There is one last issue to take care of: since we activated the element's subscriptions manually, we also need to deactivate them manually when we don't need the element anymore, otherwise they will never be deactivated and garbage collected. Typically, the Javascript modal library will provide some kind of `onClose` hook, which is exactly what we need. So, our final code could look something like this:

```scala
val contentRoot: DetachedRoot[Div] = renderDetached(
content, // Laminar element
activateNow = false // !!! see activation code below
)

def openModal() {
JsModalLibrary.renderModal(
contentRoot.ref,
onClose = contentRoot.deactivate // !!!
)
// re-activate subscriptions every time the modal is opened
contentRoot.activate()
}
```


### Rendering Third Party HTML or SVG Elements

If you want to render a native HTML element (`dom.html.Element`) created a third party library, you can convert it into a Laminar element using `foreignHtmlElement` or `foreignSvgElement`. Once you have that, you can perform all the usual Laminar operations on it:
Expand Down

0 comments on commit 6f83e77

Please sign in to comment.