Is there a way to "break" the flow of composables ...
# compose-web
d
Is there a way to "break" the flow of composables for a single composable? Basically change a composable's element parent behind the scenes? (More details in đŸ§” )
For example, I want my html to look like this:
Copy code
<body>
  <div id="root">...</div>
  <div id="modals">...</div>
</body>
and my code might look like this:
Copy code
@Composable
fun SomeWidget() {
  var showModal by remember { mutableStateOf(false) }
  ...
  Button(onClick = { showModal = true }
  if (showModal) {
     Modal { ... }
  }
}
where I want the
Modal
widget (which I'm going to write) to basically not really be under the
SomeWidget
element in the tree, but rather moved into the
modals
div on creation.
I believe Jetpack Compose popups are like this? And React has something called portals allowing for this. It's good for things like popups and dialogs, as I understand it.
I experimented a bit so far and can successfully get the widget to move into a different parent when entering composition, but then later when it exists the composition, it crashes on a "assertNotNull" exception (before I get a chance to handle the
onDispose
event.
z
You can look at the impl of the Jetpack compose popup to see how this works. Eg in android I believe it uses an effect to launch and dispose a platform modal, which it then uses the platform integration setContent to pass its content block to. You could do something similar here
d
Ah that's a good idea
I'm not familiar with
setContent
, I'll take a look
z
Idk anything about compose web, but whatever mechanism you use to host a composition inside a dom
d
For completion of this thread, the code I'm currently using looks something like this:
Copy code
ref { element ->
  val modals = document.getElementById("modals")!!
  modals.append(element)
  onDispose {
    modals.removeChild(element)
  }
}
and the callstack I'm getting when the modal goes away is:
Copy code
"NullPointerException
    at THROW_NPE (<http://localhost:8080/app.js:25496:11>)
    at ensureNotNull (<http://localhost:8080/app.js:25489:7>)
    at DomNodeWrapper.remove_fwj0yb_k$ (<http://localhost:8080/app.js:72921:25>)
    at DomApplier.remove_fwj0yb_k$ (<http://localhost:8080/app.js:72974:34>)
    at <http://localhost:8080/app.js:45642:15>
    at applyChangesInLocked (<http://localhost:8080/app.js:48261:17>)
    at CompositionImpl.applyChanges_yo70f0_k$ (<http://localhost:8080/app.js:49202:5>)
    at <http://localhost:8080/app.js:51058:26>
    at <http://localhost:8080/app.js:72227:20>"
which feels like my moving the element is causing assumptions in Compose for Web to break
(This NPE happens even if I don't call
removeChild
in the
onDispose
call.)
z
I don’t know enough about how compose web is wired up to know how to troubleshoot this further unfortunately
d
No worries! I'm just dropping additional details here in case I don't make any more progress today and someone on the Web team sees this overnight.
z
If you can define your
modals
element in compose somehow, ie a modal host, you could hoist a snapshot state object that holds the list of current modals, add to it in the body of
Modal
, and then in your modal host thing read that and compose based on the state.
d
Yeah, I'm not 100% sure I know all those words but it sounds like the shape of what I'm trying to do
z
The thing holding the modal state needs to be a snapshot state object (eg a mutableStateListOf) so you can write to it in the composition body directly. And of course to invalidate on disposal.
d
This would require having parallel composition trees?
I'm trying to figure out if that's even what
setContent
does
(Apologies as I'm at the point where I don't know enough to be able to ask good questions 🙂)
z
Let me get my laptop and write some pseudo code.
d
Like, here's the root of a CfW project:
Copy code
renderComposable(rootElementId = "root") {
   ...
}
I believe this is the equivalent of Jetpack Compose's
setContent
entry point, now that I'm looking at and recalling it
I'm assuming calling that function blocks and wasn't sure if I could have multiple of those running side by side. That's what I meant above my mentioning parallel composition trees.
I appreciate your help! Please don't spend too much time on this. Even a very rough gist would help and I could run with it from there.
z
Copy code
private val LocalModalState = staticCompositionLocalOf<ModalHostState?> { null }

private class ModalHostState {
  private val modals = mutableStateListOf<@Composable () -> Unit>()

  fun createModal(): Modal = Modal().also{
    modals += it
  }

  inline fun forEachModal(block: (Modal) -> Unit) {
    modals.forEach(block)
  }

  inner class Modal {
    var content by mutableStateOf<(@Composable () -> Unit)?>(null)
    fun dismiss() { modals -= this }
  }
}

@Composable internal fun ModalHost(content: @Composable () -> Unit) {
  val state = remember { ModalHostState() }
  CompositionLocalProvider(LocalModalState provides state, content = content)
  state.forEachModal { modal ->
    modal.content?.let { content ->
      YourModalDecorationBox {
        content()
      }
    }
  }
}

@Composable fun Modal(content: @Composable () -> Unit) {
  val state = checkNotNull(LocalModalState.current) {
    “Can’t show modal outside of a ModalHost”
  }
  val modal = remember(state) { state.createModal() }
  modal.content = content
  DisposableEffect(modal) {
    onDispose {
      modal.dismiss()
    }
  }
}
ugh slack * shakes fist *
d
😄
Ah yeah I think this is exactly what I need
And at the very least I appreciate seeing new edges to APIs I've used
z
sorry still editing
d
👍
I'll wait
z
ok done lol
d
So the one thing I don't understand is when I'm supposed to call ModalHost
Right now I have essentially a root
setContent
Also,
Copy code
fun createModal(): Modal = Modal().also{
    modals += it
  }
Modal is a function not a type. Is that supposed to be ModalHostState or something?
z
no it’s that inner class
d
n/m just saw the inner class
z
so it’s both, yea – could have used more distinct names, sorry
i didn’t try to compile this if you couldn’t tell 😜
d
No worries
z
and you’d call
ModalHost
near the root of your composition, around whatever “body”/non-modal content you have
d
So basically
Copy code
setContent {
   ModalHost {
     OtherStuff()
   }
}
?
z
exactly
d
OK perfect
There's no reason to have multiple modal hosts is there?
There will always basically just be one?
z
you could have nested modals – put a
ModalHost
inside a
Modal
🔄
but otherwise no
d
Ah gotcha
OK thanks again, so much
I'll give a shot
z
ok after all this though, i think it’s even better to hoist the modal state even further so that your view model/presenter thing, whatever is the source of truth of the state backing your composition, explicitly exposes a list of modal models to show. Then your business/nav logic can more directly control what modals are showing, it’s more testable, and harder to get bugs where something is showing a modal that shouldn’t be in a large codebase that you have to track down.
d
I'm not sure I follow "hoist the model state even further"
I think -- I think it's working...
Not 100% sure, actually. (I mean, it's working but may just be chance)
Will play around a bit more so I'm more confident and will avoid spamming here too much. But still, thanks so much for your help!
Zach you are a gentleman and a scholar (my local experiments are working and this even lets me remove some zIndex values which I believe are considered a code smell in the world of html / css)
z
The one thing my code didn’t have was an explicit layout to arrange the modals over the content, just assumed whatever you put the host in would lay things out over each other. Idk how layouts work in compose web. In Jetpack Compose I would probably just use a
Box
.
d
Yes for my case it will just be things layered one on top of the other. The most important part was getting the children elements that become modals out of the deep tree hierarchy.
I actually implemented
Box
in my library using a web grid but here it can also easily be done with a simple
div
with something called an absolute position.
Just to wrap this up -- if anyone is curious, here's my current implementation: https://github.com/varabyte/kobweb/blob/d3dacc9e0aeefadeb6cb139f3fe00e540cb1e454/f[
]ts/src/jsMain/kotlin/com/varabyte/kobweb/silk/defer/Deferred.kt which is very similar to what Zach suggested, but I went ahead and made it all private, exposing only a
deferRender
method, where it gets used like so (in one example): https://github.com/varabyte/kobweb/blob/d3dacc9e0aeefadeb6cb139f3fe00e540cb1e454/f[
]ain/kotlin/com/varabyte/kobweb/silk/components/overlay/Modal.kt
Copy code
fun Modal(modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
    deferRender {
        Box(ModalBackdropStyle.toModifier(), contentAlignment = Alignment.TopCenter) {
            Box(ModalStyle.toModifier().then(modifier)) {
                content()
            }
        }
    }
}
Seems to be working so far, and I'm really happy with the approach. Knock on wood!