Łukasz Makuch

Łukasz Makuch

Portals in Vue 2 don't work with Slot Props from Scoped Slots

A portal

There's a caveat worth keeping in mind when using portals through the means of portal-vue in Vue 2. You may notice that adding slot props prevents the view from updating, even if you don't actually use them. If there's a v-slot, the template doesn't refresh.

✅ How to make sure the teleported content reacts to changes?

I believe that the only way to guarantee components update properly when they are sent through <portal> in Vue 2 is to use render functions instead of templates. To simplify writing render functions, consider enabling JSX.

You might have seen this comment by Thorsten Lünborg (the author of portal-vue) in which he proposes a couple of interesting workarounds. Sadly, none of them worked reliably in my testing.

❌ What won't work?

There are steps that, when taken, may trick us into thinking we've solved the bug. However, in reality the bug is still there and may reoccurr in any time. The following steps do not guarantee that that the problem will be solved once for good:

  • adding v-if="true"
  • adding keys
  • extracting more components
  • using different scoped slots syntax in templates

But why do slot props break portals in Vue 2 in the first place?

Vue 2 doesn't have native portals; instead, they're provided by a third party library called portal-vue. In a nutshell, it works by sending a piece of the render function from one component to another.

Vue 2.6 introduced a performance optimization of scoped slots. That's why slot-scope has been deprecated. But implementing this optimization proved to be a challenging task.

What we can observe is that as soon as we go from this:

<portal to="destination">
    <with-slot-props>
      hello world
    </with-slot-props>
</portal>

to this:

<portal to="destination">
    <with-slot-props v-slot="props">
      hello world
    </with-slot-props>
</portal>

it breaks.

Why?

I found vue-compiler-online by Jack Chi extremely helpful in understanding what exactly happened.

This is the actual render function created by the template compiler when we don't use slot props:

return _c('portal', {
    attrs: {
        "to": "destination"
    }
}, [_c('with-slot-props', [_v("hello world")])], 1)

It changes quite drastically as soon as we add v-slot or slot-scope:

return _c('portal', {
    attrs: {
        "to": "destination"
    }
}, [_c('with-slot-props', {
    // a mysterious _u function
    scopedSlots: _u([{
        key: "default",
        // a new function
        fn: function(props) {
            return [_v(
                "hello world"
            )]
        }
    }])
})], 1)

There's a new function and it's not your typical scopedSlots function.

_u stands for resolveScopedSlots and when called like this, it produces a result with { $stable: true }, which caches the output and thus prevents it from being re-rendered in portals.

How does writing the render function ourselves fix the problem?

If we write this render function ourselves, it won't be incorrectly cached by resolveScopedSlots:

return h('portal', { attrs: { to: 'destination' } }, [
  h('with-slot-props', {
    scopedSlots: {
      default: props => h('div', 'hello world')
    }
  })
]);

How to write render functions and not go crazy?

I don't know about you, but I don't usually write my HTML in raw render functions, and because I'm not exposed to them, I don't find them readable. Fortunately, JSX can make render functions with scoped slots prettier:

<portal to="destination">
  <with-slot-props scopedSlots={{
    default: (props) => <div> hello world </div>
  }} />
</portal>

Back to the familiar syntax, yay!

If you want, you can play with JSX more in the JSX-Vue2 Playground.

Why don't workarounds like v-if="true", key, and slot-scope work?

Using the old slot-scope syntax compiles to exactly the same output as using v-slot.

Adding unique keys is possible only when we can predict what will end up in the portal upfront.

Exctacting the content of the portal into a separate component works only when all the props we pass to it are the slot props. Other values, like props from the parent component, will become stale.

Assigning v-if="true" is the most interesting trap. This is what it compiles to:

return _c('portal', {
  attrs: {
      "to": "destination"
  }
}, [(true) ? _c('base-layout', {
  scopedSlots: _u([{
      key: "default",
      fn: function(props) {
          return [_c('div', [_v(
              "hello world"
          )])]
      }
                   // A mysterious hash
  }], null, false, 2480752568)
}) : _e()], 1)

Now the result is still cached, but this time under the 2480752568 key. It's derived from the template inside of the scoped slot. So if we change "hello world" to "hello code", it becomes 212219543 instead of 2480752568. The good news is that it often prevents the template from going stale! The bad news is that it ignores variables. So if we have the following template:

<button>hello {{name}}</button>

and we swap it for an identical template with a different name value, it won't refresh.

Summary

I know that some people choose Vue solely because they are not big fans of JSX. However, JSX in render functions is the closest to reliable templates we have if we want to use Vue 2, portal-vue and slot props.

From the author of this blog

  • howlong.app - a timesheet built for freelancers, not against them!